diff --git a/.gitignore b/.gitignore index aee5ed34fa..0383866fec 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,9 @@ tools/spire-plugingen/spire-plugingen # Editor specific configuration .idea -.vscode +.vscode/* +!.vscode/launch.json +!.vscode/tasks.json # Runtime version manager specific configuration # asdf config file @@ -41,3 +43,12 @@ oci/ # Go workspace files go.work go.work.sum + +# VSCode debug artifacts +__debug_bin* + +.agent-data/ +.loadtest-data/* +cmd/cassandraplugin/cassandraplugin + +import diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..1a1ceddaba --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,72 @@ +{ + "configurations": [ + { + "name": "Run migrations", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/pkg/server/datastore/cassandra/migrationhelper/main.go", + "cwd": "${workspaceFolder}" + }, + { + "name": "Run SPIRE server - cassandra", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/cmd/spire-server/main.go", + "args": [ + "run", + "-config", + "./dev/server/server_cass.conf" + ], + "cwd": "${workspaceFolder}" + }, + { + "name": "Run SPIRE server - cassandra external", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/cmd/spire-server/main.go", + "args": [ + "run", + "-config", + "./dev/server/server_cass_external.conf" + ], + "cwd": "${workspaceFolder}" + }, + { + "name": "Run SPIRE server - postgres", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/cmd/spire-server/main.go", + "args": [ + "run", + "-config", + "./dev/server/server_pg.conf" + ], + "cwd": "${workspaceFolder}" + }, + { + "name": "Debug Cassandra integration tests", + "type": "go", + "request": "launch", + "mode": "test", + "program": "${workspaceFolder}/pkg/server/datastore", + "buildFlags": [ + "-ldflags", + "-X github.com/spiffe/spire/pkg/server/datastore_test.TestDialect=cassandra -X github.com/spiffe/spire/pkg/server/datastore_test.TestConnString=[\"localhost:9053\"];spire_test" + ], + "cwd": "${workspaceFolder}/pkg/server/datastore", + "args": [ + "-test.parallel", + "1", + "-test.v", + "-testify.m", + "^(TestCreateOrReturnRegistrationEntry)$" + ], + "preLaunchTask":"start cassandra", + "postDebugTask": "stop cassandra", + }, + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000..d766038c4e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,20 @@ +{ + "tasks": [ + { + "label": "start cassandra", + "type": "shell", + "command": "bash", + "args": [ + "${workspaceFolder}/test/integration/cassandra-suites/datastore-cassandra-focus/extras/debug-setup.sh" + ] + }, + { + "label": "stop cassandra", + "type": "shell", + "command": "bash", + "args": [ + "${workspaceFolder}/test/integration/cassandra-suites/datastore-cassandra-focus/extras/debug-teardown.sh" + ] + } + ] +} \ No newline at end of file diff --git a/cmd/cassandraplugin/Makefile b/cmd/cassandraplugin/Makefile new file mode 100644 index 0000000000..b1399a1a1c --- /dev/null +++ b/cmd/cassandraplugin/Makefile @@ -0,0 +1,2 @@ +build: + go build -o cassandraplugin main.go \ No newline at end of file diff --git a/cmd/cassandraplugin/main.go b/cmd/cassandraplugin/main.go new file mode 100644 index 0000000000..c2337639c7 --- /dev/null +++ b/cmd/cassandraplugin/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "github.com/spiffe/spire-plugin-sdk/pluginmain" + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/server/datastore/cassandra" +) + +type Plugin struct { + cassandra.Plugin +} + +func main() { + plugin := cassandra.NewPlugin() + pluginmain.Serve( + datastorev1.DataStorePluginServer(plugin), + configv1.ConfigServiceServer(plugin), + ) +} diff --git a/cmd/spire-server/cli/run/run.go b/cmd/spire-server/cli/run/run.go index e170d3837d..765dc1a611 100644 --- a/cmd/spire-server/cli/run/run.go +++ b/cmd/spire-server/cli/run/run.go @@ -116,6 +116,7 @@ type experimentalConfig struct { SQLTransactionTimeout string `hcl:"sql_transaction_timeout"` RequirePQKEM bool `hcl:"require_pq_kem"` WITKeyType string `hcl:"wit_key_type"` + AllowPluggableDatastore bool `hcl:"allow_pluggable_datastore"` Flags fflag.RawConfig `hcl:"feature_flags"` @@ -775,6 +776,8 @@ func NewServerConfig(c *Config, logOptions []log.Option, allowUnknownConfig bool sc.EventsBasedCache = c.Server.Experimental.EventsBasedCache sc.AuthOpaPolicyEngineConfig = c.Server.Experimental.AuthOpaPolicyEngine + sc.ExperimentalAllowPluggableDatastore = c.Server.Experimental.AllowPluggableDatastore + for _, f := range c.Server.Experimental.Flags { sc.Log.Warnf("Developer feature flag %q has been enabled", f) } diff --git a/doc/plugin_server_datastore_cassandra.md b/doc/plugin_server_datastore_cassandra.md new file mode 100644 index 0000000000..24f42a075e --- /dev/null +++ b/doc/plugin_server_datastore_cassandra.md @@ -0,0 +1,17 @@ +# Server plugin: DataStore "cassandra" + +The `cassandra` plugin is an experimental datastore plugin being prototyped to support allowing SPIRE servers to use [Apache Cassandra](https://cassandra.apache.org/) as a highly-available distributed database backend. This plugin is not officially supported and requires enabling an experimental flag in the `server` stanza of the SPIRE Server configuration file: +```hcl +server { + ... + experimental { + allow_pluggable_datastore = true + } +} +``` + +Adventurous users wanting to explore this plugin should be aware that it should be expected to be unstable and potentially lose data until it has been hardened. This plugin should not be run in production. This plugin has been developed and tested against Cassandra 5.0.6, and will not support versions of Cassandra prior to v5.0. + +When the Cassandra datastore is used, the + +## Cassandra configuration diff --git a/go.mod b/go.mod index 91a645e983..11b5398c7d 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/Microsoft/go-winio v0.6.2 github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 + github.com/apache/cassandra-gocql-driver/v2 v2.0.0 github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/config v1.32.12 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 @@ -40,7 +41,7 @@ require ( github.com/blang/semver/v4 v4.0.0 github.com/cenkalti/backoff/v4 v4.3.0 github.com/docker/docker v28.5.2+incompatible - github.com/envoyproxy/go-control-plane/envoy v1.37.0 + github.com/envoyproxy/go-control-plane/envoy v1.36.0 github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-sql-driver/mysql v1.9.3 @@ -80,6 +81,7 @@ require ( github.com/spiffe/spire-api-sdk v1.2.5-0.20260320104153-99d433165a01 github.com/spiffe/spire-plugin-sdk v1.4.4-0.20251120194148-791bbd274dc7 github.com/stretchr/testify v1.11.1 + github.com/tjons/cassandra-toolbox v0.0.0-20260314211553-cdb30cc01b34 github.com/uber-go/tally/v4 v4.1.17 github.com/valyala/fastjson v1.6.10 golang.org/x/crypto v0.50.0 @@ -145,6 +147,7 @@ require ( github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect @@ -330,3 +333,5 @@ require ( sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) + +replace github.com/spiffe/spire-plugin-sdk => github.com/geico/spire-plugin-sdk v0.0.0-20260418165235-b38c1edf5e75 diff --git a/go.sum b/go.sum index 52bc94ea47..2945f8bdaa 100644 --- a/go.sum +++ b/go.sum @@ -111,6 +111,8 @@ github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgp github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/apache/cassandra-gocql-driver/v2 v2.0.0 h1:Omnzb1Z/P90Dr2TbVNu54ICQL7TKVIIsJO231w484HU= +github.com/apache/cassandra-gocql-driver/v2 v2.0.0/go.mod h1:QH/asJjB3mHvY6Dot6ZKMMpTcOrWJ8i9GhsvG1g0PK4= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= @@ -223,8 +225,8 @@ github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpk github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= @@ -268,8 +270,8 @@ github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bF github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= -github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= @@ -299,6 +301,8 @@ github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa h1:RDBNVkRviHZtvD github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/geico/spire-plugin-sdk v0.0.0-20260418165235-b38c1edf5e75 h1:i6YaPFYPd2WLuJvkAwj7HS2YoP1JneRLoc0Wvp5BdLg= +github.com/geico/spire-plugin-sdk v0.0.0-20260418165235-b38c1edf5e75/go.mod h1:QvrRDiBlXiJ7kNd176ZHsF5eklxxeTRgJSu2CXe0MKw= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= @@ -707,6 +711,9 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= +github.com/pierrec/lz4/v4 v4.1.8 h1:ieHkV+i2BRzngO4Wd/3HGowuZStgq6QkPsD1eolNAO4= +github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM= github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -754,6 +761,8 @@ github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8A github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= @@ -815,8 +824,6 @@ github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMps github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spiffe/spire-api-sdk v1.2.5-0.20260320104153-99d433165a01 h1:FA0UhZ5fOca/d/ogmVHif1eDARpMo4FLWUnav8JzEts= github.com/spiffe/spire-api-sdk v1.2.5-0.20260320104153-99d433165a01/go.mod h1:9hXJcMzatM1KwAtBDO3s6HccDCic++/5c2yOc5Iln8Y= -github.com/spiffe/spire-plugin-sdk v1.4.4-0.20251120194148-791bbd274dc7 h1:OAvr7TNirmBpXnAp82cTosuB+JAus5cyFCRqXHE0WHs= -github.com/spiffe/spire-plugin-sdk v1.4.4-0.20251120194148-791bbd274dc7/go.mod h1:QvrRDiBlXiJ7kNd176ZHsF5eklxxeTRgJSu2CXe0MKw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= @@ -849,6 +856,8 @@ github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC6 github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/tjons/cassandra-toolbox v0.0.0-20260314211553-cdb30cc01b34 h1:ymRJ3UeFD5u+I7HxbiBXX65NXzJ/H6DRRsJSX4LVFDE= +github.com/tjons/cassandra-toolbox v0.0.0-20260314211553-cdb30cc01b34/go.mod h1:r7A2k5T3lg+7FRTLah8mAeUaephHObjiihDz6COTBSM= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= diff --git a/pkg/common/catalog/builtin.go b/pkg/common/catalog/builtin.go index 5153652047..45b6582380 100644 --- a/pkg/common/catalog/builtin.go +++ b/pkg/common/catalog/builtin.go @@ -35,6 +35,10 @@ type BuiltInConfig struct { // HostServices are the host service servers provided to the plugin. HostServices []pluginsdk.ServiceServer + + // MaxGrpcMessageSize is the maximum gRPC message size that the plugin should be configured to send. + // Defaults to 4 MB if not set. + MaxGrpcMessageSize int } func LoadBuiltIn(ctx context.Context, builtIn BuiltIn, config BuiltInConfig) (_ Plugin, err error) { @@ -51,6 +55,7 @@ func loadBuiltIn(ctx context.Context, builtIn BuiltIn, config BuiltInConfig) (_ pluginName: builtIn.Name, log: config.Log, hostServices: config.HostServices, + maxGrpcMessageSize: config.MaxGrpcMessageSize, } var closers closerGroup @@ -61,14 +66,14 @@ func loadBuiltIn(ctx context.Context, builtIn BuiltIn, config BuiltInConfig) (_ }() closers = append(closers, dialer) - builtinServer, serverCloser := newBuiltInServer(config.Log) + builtinServer, serverCloser := newBuiltInServer(config.Log, config.MaxGrpcMessageSize) closers = append(closers, serverCloser) pluginServers := append([]pluginsdk.ServiceServer{builtIn.Plugin}, builtIn.Services...) private.Register(builtinServer, pluginServers, logger, dialer) - builtinConn, err := startPipeServer(builtinServer, config.Log) + builtinConn, err := startPipeServer(builtinServer, config.Log, config.MaxGrpcMessageSize) if err != nil { return nil, err } @@ -82,19 +87,22 @@ func loadBuiltIn(ctx context.Context, builtIn BuiltIn, config BuiltInConfig) (_ return newPlugin(ctx, builtinConn, info, config.Log, closers, config.HostServices) } -func newBuiltInServer(log logrus.FieldLogger) (*grpc.Server, io.Closer) { +func newBuiltInServer(log logrus.FieldLogger, maxGrpcMessageSize int) (*grpc.Server, io.Closer) { drain := &drainHandlers{} return grpc.NewServer( grpc.ChainStreamInterceptor(drain.StreamServerInterceptor, streamPanicInterceptor(log)), grpc.ChainUnaryInterceptor(drain.UnaryServerInterceptor, unaryPanicInterceptor(log)), + grpc.MaxSendMsgSize(maxGrpcMessageSize), + grpc.MaxRecvMsgSize(maxGrpcMessageSize), ), closerFunc(drain.Wait) } type builtinDialer struct { - pluginName string - log logrus.FieldLogger - hostServices []pluginsdk.ServiceServer - conn *pipeConn + pluginName string + log logrus.FieldLogger + hostServices []pluginsdk.ServiceServer + conn *pipeConn + maxGrpcMessageSize int } func (d *builtinDialer) DialHost(context.Context) (grpc.ClientConnInterface, error) { @@ -102,7 +110,7 @@ func (d *builtinDialer) DialHost(context.Context) (grpc.ClientConnInterface, err return d.conn, nil } server := newHostServer(d.log, d.pluginName, d.hostServices) - conn, err := startPipeServer(server, d.log) + conn, err := startPipeServer(server, d.log, d.maxGrpcMessageSize) if err != nil { return nil, err } @@ -122,7 +130,7 @@ type pipeConn struct { io.Closer } -func startPipeServer(server *grpc.Server, log logrus.FieldLogger) (_ *pipeConn, err error) { +func startPipeServer(server *grpc.Server, log logrus.FieldLogger, maxGrpcMessageSize int) (_ *pipeConn, err error) { var closers closerGroup pipeNet := newPipeNet() @@ -146,6 +154,10 @@ func startPipeServer(server *grpc.Server, log logrus.FieldLogger) (_ *pipeConn, "passthrough:IGNORED", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(pipeNet.DialContext), + grpc.WithDefaultCallOptions( + grpc.MaxCallSendMsgSize(maxGrpcMessageSize), + grpc.MaxCallRecvMsgSize(maxGrpcMessageSize), + ), ) if err != nil { return nil, err diff --git a/pkg/common/catalog/catalog.go b/pkg/common/catalog/catalog.go index 3de88de7d2..1e61d8c8f0 100644 --- a/pkg/common/catalog/catalog.go +++ b/pkg/common/catalog/catalog.go @@ -82,7 +82,7 @@ type Facade interface { InitInfo(info PluginInfo) // InitLog initializes the facade with the logger for the loaded plugin - // that provides the service server. + // that provides the service server. // TODO(tjons): this is not correct InitLog(log logrus.FieldLogger) } @@ -319,21 +319,23 @@ func makePluginLog(log logrus.FieldLogger, pluginConfig PluginConfig) logrus.Fie func loadPlugin(ctx context.Context, builtIns []BuiltIn, pluginConfig PluginConfig, pluginLog logrus.FieldLogger, hostServices []pluginsdk.ServiceServer) (*pluginImpl, error) { if pluginConfig.IsExternal() { return loadExternal(ctx, externalConfig{ - Name: pluginConfig.Name, - Type: pluginConfig.Type, - Path: pluginConfig.Path, - Args: pluginConfig.Args, - Checksum: pluginConfig.Checksum, - Log: pluginLog, - HostServices: hostServices, + Name: pluginConfig.Name, + Type: pluginConfig.Type, + Path: pluginConfig.Path, + Args: pluginConfig.Args, + Checksum: pluginConfig.Checksum, + Log: pluginLog, + HostServices: hostServices, + MaxGrpcMessageSize: pluginConfig.MaxGrpcMessageSize, }) } for _, builtIn := range builtIns { if pluginConfig.Name == builtIn.Name { return loadBuiltIn(ctx, builtIn, BuiltInConfig{ - Log: pluginLog, - HostServices: hostServices, + Log: pluginLog, + HostServices: hostServices, + MaxGrpcMessageSize: pluginConfig.MaxGrpcMessageSize, }) } } diff --git a/pkg/common/catalog/config.go b/pkg/common/catalog/config.go index 2d61d229ea..593cbfaabd 100644 --- a/pkg/common/catalog/config.go +++ b/pkg/common/catalog/config.go @@ -12,6 +12,8 @@ import ( "github.com/hashicorp/hcl/hcl/token" ) +const DefaultMaxGrpcMessageSize int = 4_194_304 // 4 MB + type PluginConfigs []PluginConfig func (cs PluginConfigs) FilterByType(pluginType string) (matching PluginConfigs, remaining PluginConfigs) { @@ -35,13 +37,14 @@ func (cs PluginConfigs) Find(pluginType, pluginName string) (PluginConfig, bool) } type PluginConfig struct { - Type string - Name string - Path string - Args []string - Checksum string - DataSource DataSource - Disabled bool + Type string + Name string + Path string + Args []string + Checksum string + DataSource DataSource + Disabled bool + MaxGrpcMessageSize int } func (c PluginConfig) IsEnabled() bool { @@ -82,12 +85,13 @@ func (d FileData) IsDynamic() bool { } type hclPluginConfig struct { - PluginCmd string `hcl:"plugin_cmd"` - PluginArgs []string `hcl:"plugin_args"` - PluginChecksum string `hcl:"plugin_checksum"` - PluginData ast.Node `hcl:"plugin_data"` - PluginDataFile *string `hcl:"plugin_data_file"` - Enabled *bool `hcl:"enabled"` + PluginCmd string `hcl:"plugin_cmd"` + PluginArgs []string `hcl:"plugin_args"` + PluginChecksum string `hcl:"plugin_checksum"` + PluginData ast.Node `hcl:"plugin_data"` + PluginDataFile *string `hcl:"plugin_data_file"` + Enabled *bool `hcl:"enabled"` + MaxGrpcMessageSize *int `hcl:"max_grpc_message_size"` } func (c hclPluginConfig) IsEnabled() bool { @@ -270,14 +274,20 @@ func pluginConfigFromHCL(pluginType, pluginName string, hclPluginConfig hclPlugi dataSource = FileData(*hclPluginConfig.PluginDataFile) } + maxGrpcMessageSize := DefaultMaxGrpcMessageSize + if hclPluginConfig.MaxGrpcMessageSize != nil { + maxGrpcMessageSize = *hclPluginConfig.MaxGrpcMessageSize + } + return PluginConfig{ - Name: pluginName, - Type: pluginType, - Path: hclPluginConfig.PluginCmd, - Args: hclPluginConfig.PluginArgs, - Checksum: hclPluginConfig.PluginChecksum, - DataSource: dataSource, - Disabled: !hclPluginConfig.IsEnabled(), + Name: pluginName, + Type: pluginType, + Path: hclPluginConfig.PluginCmd, + Args: hclPluginConfig.PluginArgs, + Checksum: hclPluginConfig.PluginChecksum, + DataSource: dataSource, + Disabled: !hclPluginConfig.IsEnabled(), + MaxGrpcMessageSize: maxGrpcMessageSize, }, nil } diff --git a/pkg/common/catalog/external.go b/pkg/common/catalog/external.go index 140ba8d3b7..e6b558a476 100644 --- a/pkg/common/catalog/external.go +++ b/pkg/common/catalog/external.go @@ -38,6 +38,11 @@ type externalConfig struct { // HostServices are the host service servers provided to the plugin. HostServices []pluginsdk.ServiceServer + + // MaxGrpcMessageSize is the maximum gRPC message size in bytes that the plugin will accept. + // This is an experimental configuration option that may be removed in the future. It is not intended to be + // used by most plugins and will default to the standard gRPC message size limit of 4MB if not set. + MaxGrpcMessageSize int } func loadExternal(ctx context.Context, config externalConfig) (*pluginImpl, error) { @@ -87,6 +92,11 @@ func loadExternal(ctx context.Context, config externalConfig) (*pluginImpl, erro }, Logger: logger, SecureConfig: secureConfig, + GRPCDialOptions: []grpc.DialOption{ + grpc.WithDefaultCallOptions( + grpc.MaxCallSendMsgSize(config.MaxGrpcMessageSize), + ), + }, }) // Ensure the loaded plugin is killed if there is a failure. diff --git a/pkg/common/telemetry/server/datastore/wrapper.go b/pkg/common/telemetry/server/datastore/wrapper.go index 14bc4244e3..bbe974b66f 100644 --- a/pkg/common/telemetry/server/datastore/wrapper.go +++ b/pkg/common/telemetry/server/datastore/wrapper.go @@ -23,6 +23,19 @@ type metricsWrapper struct { m telemetry.Metrics } +// Added temporarily to satisfy the datastore interface +func (w metricsWrapper) Close() error { + return w.ds.Close() +} + +func (w metricsWrapper) Type() string { + return w.ds.Type() +} + +func (w metricsWrapper) Name() string { + return w.ds.Name() +} + func (w metricsWrapper) AppendBundle(ctx context.Context, bundle *common.Bundle) (_ *common.Bundle, err error) { callCounter := StartAppendBundleCall(w.m) defer callCounter.Done(&err) diff --git a/pkg/common/telemetry/server/datastore/wrapper_test.go b/pkg/common/telemetry/server/datastore/wrapper_test.go index b5ac06d865..e0906966d2 100644 --- a/pkg/common/telemetry/server/datastore/wrapper_test.go +++ b/pkg/common/telemetry/server/datastore/wrapper_test.go @@ -10,6 +10,7 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/server/datastore" "github.com/spiffe/spire/proto/spire/common" @@ -332,6 +333,26 @@ type fakeDataStore struct { err error } +func (ds *fakeDataStore) Name() string { + return "fake" +} + +func (ds *fakeDataStore) Type() string { + return "fake" +} + +func (ds *fakeDataStore) Close() error { + return nil +} + +func (ds *fakeDataStore) Configure(ctx context.Context, req *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) { + return &configv1.ConfigureResponse{}, nil // This is intentionally a no-op for the fake datastore +} + +func (ds *fakeDataStore) Validate(ctx context.Context, req *configv1.ValidateRequest) (*configv1.ValidateResponse, error) { + return &configv1.ValidateResponse{}, nil // This is intentionally a no-op for the fake datastore +} + func (ds *fakeDataStore) SetError(err error) { ds.err = err } diff --git a/pkg/server/cache/entrycache/fullcache_test.go b/pkg/server/cache/entrycache/fullcache_test.go index d415a9a4fd..730ce25285 100644 --- a/pkg/server/cache/entrycache/fullcache_test.go +++ b/pkg/server/cache/entrycache/fullcache_test.go @@ -16,6 +16,7 @@ import ( "github.com/sirupsen/logrus/hooks/test" "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" "github.com/spiffe/spire/pkg/common/protoutil" "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/pkg/server/datastore" @@ -819,7 +820,9 @@ func newSQLPlugin(ctx context.Context, tb testing.TB) datastore.DataStore { require.FailNowf(tb, "Unsupported external test dialect %q", TestDialect) } - err := p.Configure(ctx, cfg) + _, err := p.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: cfg, + }) require.NoError(tb, err) return p diff --git a/pkg/server/catalog/catalog.go b/pkg/server/catalog/catalog.go index 5e56e9c606..de112cc6ff 100644 --- a/pkg/server/catalog/catalog.go +++ b/pkg/server/catalog/catalog.go @@ -22,12 +22,13 @@ import ( ds_telemetry "github.com/spiffe/spire/pkg/common/telemetry/server/datastore" km_telemetry "github.com/spiffe/spire/pkg/common/telemetry/server/keymanager" "github.com/spiffe/spire/pkg/server/cache/dscache" - "github.com/spiffe/spire/pkg/server/datastore" + ds_core "github.com/spiffe/spire/pkg/server/datastore" ds_sql "github.com/spiffe/spire/pkg/server/datastore/sqlstore" "github.com/spiffe/spire/pkg/server/hostservice/agentstore" "github.com/spiffe/spire/pkg/server/hostservice/identityprovider" "github.com/spiffe/spire/pkg/server/plugin/bundlepublisher" "github.com/spiffe/spire/pkg/server/plugin/credentialcomposer" + "github.com/spiffe/spire/pkg/server/plugin/datastore" "github.com/spiffe/spire/pkg/server/plugin/keymanager" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/jointoken" @@ -68,14 +69,18 @@ type Config struct { IdentityProvider *identityprovider.IdentityProvider AgentStore *agentstore.AgentStore HealthChecker health.Checker + + Experimental ExperimentalConfig } -type datastoreRepository struct{ datastore.Repository } +type ExperimentalConfig struct { + AllowPluggableDatastore bool +} type Repository struct { bundlePublisherRepository credentialComposerRepository - datastoreRepository + dataStoreRepository keyManagerRepository nodeAttestorRepository notifierRepository @@ -84,6 +89,8 @@ type Repository struct { log logrus.FieldLogger dsCloser io.Closer catalog *catalog.Catalog + + experimentalPluggableDatastore bool } type dsConfigurer struct { @@ -91,15 +98,23 @@ type dsConfigurer struct { } func (c *dsConfigurer) Configure(ctx context.Context, _ catalog.CoreConfig, configuration string) error { - return c.ds.Configure(ctx, configuration) + _, err := c.ds.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: configuration, + }) + return err } func (c *dsConfigurer) Validate(ctx context.Context, coreConfig catalog.CoreConfig, configuration string) (*configv1.ValidateResponse, error) { - return c.ds.Validate(ctx, coreConfig, configuration) + return c.ds.Validate(ctx, &configv1.ValidateRequest{ + HclConfiguration: configuration, + CoreConfiguration: &configv1.CoreConfiguration{ + TrustDomain: coreConfig.TrustDomain.String(), + }, + }) } func (repo *Repository) Plugins() map[string]catalog.PluginRepo { - return map[string]catalog.PluginRepo{ + repos := map[string]catalog.PluginRepo{ bundlePublisherType: &repo.bundlePublisherRepository, credentialComposerType: &repo.credentialComposerRepository, keyManagerType: &repo.keyManagerRepository, @@ -107,6 +122,12 @@ func (repo *Repository) Plugins() map[string]catalog.PluginRepo { notifierType: &repo.notifierRepository, upstreamAuthorityType: &repo.upstreamAuthorityRepository, } + + if repo.experimentalPluggableDatastore { + repos[dataStoreType] = &repo.dataStoreRepository + } + + return repos } func (repo *Repository) Services() []catalog.ServiceRepo { @@ -145,7 +166,8 @@ func Load(ctx context.Context, config Config) (_ *Repository, err error) { } repo := &Repository{ - log: config.Log, + log: config.Log, + experimentalPluggableDatastore: config.Experimental.AllowPluggableDatastore, } defer func() { if err != nil { @@ -159,33 +181,52 @@ func Load(ctx context.Context, config Config) (_ *Repository, err error) { // Strip out the Datastore plugin configuration and load the SQL plugin // directly. This allows us to bypass gRPC and get rid of response limits. - dataStoreConfigs, pluginConfigs := config.PluginConfigs.FilterByType(dataStoreType) - sqlDataStore, err := loadSQLDataStore(ctx, config, coreConfig, dataStoreConfigs) - if err != nil { - return nil, err - } - repo.dsCloser = sqlDataStore + dataStoreConfigs, otherConfigs := config.PluginConfigs.FilterByType(dataStoreType) - repo.catalog, err = catalog.Load(ctx, catalog.Config{ - Log: config.Log, - CoreConfig: coreConfig, - PluginConfigs: pluginConfigs, + catalogConfig := catalog.Config{ + Log: config.Log, + CoreConfig: coreConfig, HostServices: []pluginsdk.ServiceServer{ identityproviderv1.IdentityProviderServiceServer(config.IdentityProvider.V1()), agentstorev1.AgentStoreServiceServer(config.AgentStore.V1()), metricsv1.MetricsServiceServer(metricsservice.V1(config.Metrics)), }, - }, repo) + } + + // When using the pluggable datastore, we need to pass all the plugin configs to the catalog so + // it can load the datastore plugin. When not using the pluggable datastore, we skip passing the + // datastore plugin configs because the SQL plugin is loaded directly. + if repo.experimentalPluggableDatastore { + catalogConfig.PluginConfigs = config.PluginConfigs + } else { + catalogConfig.PluginConfigs = otherConfigs + } + + if !repo.experimentalPluggableDatastore { + sqlStore, err := loadSQLDataStore(ctx, config, coreConfig, dataStoreConfigs) + if err != nil { + return nil, err + } + + repo.SetDataStore(sqlStore) + repo.dsCloser = sqlStore + } + + repo.catalog, err = catalog.Load(ctx, catalogConfig, repo) if err != nil { return nil, err } - var dataStore datastore.DataStore = sqlDataStore - _ = config.HealthChecker.AddCheck("catalog.datastore", &datastore.Health{ - DataStore: dataStore, + if config.Experimental.AllowPluggableDatastore { + config.Log.WithField(telemetry.Reconfigurable, false).Info("Configured Pluggable DataStore; expect unstable behavior") + repo.dsCloser = repo.DataStore + } + + _ = config.HealthChecker.AddCheck("catalog.datastore", &ds_core.Health{ + DataStore: repo.GetDataStore(), }) - dataStore = ds_telemetry.WithMetrics(dataStore, config.Metrics) + dataStore := ds_telemetry.WithMetrics(repo.GetDataStore(), config.Metrics) dataStore = dscache.New(dataStore, clock.New()) repo.SetDataStore(dataStore) @@ -215,14 +256,19 @@ func ValidateConfig(ctx context.Context, config Config) (pluginNotes map[string] datastorePluginId := fmt.Sprintf("%s \"%s\"", dataStoreType, "sql") if len(dataStoreConfigs) == 0 { pluginNotes[datastorePluginId] = append(pluginNotes[datastorePluginId], "'datastore' must be configured") - } else { + } else if !config.Experimental.AllowPluggableDatastore { dsConfigString, err := catalog.GetPluginConfigString(dataStoreConfigs[0]) if err != nil { return nil, fmt.Errorf("failed to get DataStore configuration: %w", err) } ds := ds_sql.New(config.Log) - resp, err := ds.Validate(ctx, coreConfig, dsConfigString) + resp, err := ds.Validate(ctx, &configv1.ValidateRequest{ + HclConfiguration: dsConfigString, + CoreConfiguration: &configv1.CoreConfiguration{ + TrustDomain: coreConfig.TrustDomain.String(), + }, + }) if err != nil { pluginNotes[datastorePluginId] = append(pluginNotes[datastorePluginId], err.Error()) } diff --git a/pkg/server/catalog/datastore.go b/pkg/server/catalog/datastore.go new file mode 100644 index 0000000000..c5c9bf888b --- /dev/null +++ b/pkg/server/catalog/datastore.go @@ -0,0 +1,36 @@ +package catalog + +import ( + "github.com/spiffe/spire/pkg/common/catalog" + "github.com/spiffe/spire/pkg/server/plugin/datastore" + "github.com/spiffe/spire/pkg/server/plugin/datastore/cassandra" +) + +type dataStoreRepository struct { + datastore.Repository +} + +func (repo *dataStoreRepository) Binder() any { + return repo.SetDataStore +} + +func (repo *dataStoreRepository) Constraints() catalog.Constraints { + return catalog.MaybeOne() +} + +func (repo *dataStoreRepository) Versions() []catalog.Version { + return []catalog.Version{ + datastoreV1Alpha1{}, + } +} + +func (repo *dataStoreRepository) BuiltIns() []catalog.BuiltIn { + return []catalog.BuiltIn{ + cassandra.BuiltIn(), + } +} + +type datastoreV1Alpha1 struct{} + +func (datastoreV1Alpha1) New() catalog.Facade { return new(datastore.V1Alpha1) } +func (datastoreV1Alpha1) Deprecated() bool { return false } diff --git a/pkg/server/config.go b/pkg/server/config.go index 0beff6052a..92b9e5e834 100644 --- a/pkg/server/config.go +++ b/pkg/server/config.go @@ -149,6 +149,10 @@ type Config struct { // DisableWITSVIDs, if true, WIT-SVID profile is disabled DisableWITSVIDs bool + + // ExperimentalAllowPluggableDatastore, if true, allows either the use of the in-tree + // Cassandra datastore as a plugin or an external datastore plugin. + ExperimentalAllowPluggableDatastore bool } type ExperimentalConfig struct{} diff --git a/pkg/server/datastore/cassandra/bundles.go b/pkg/server/datastore/cassandra/bundles.go new file mode 100644 index 0000000000..102df3ced7 --- /dev/null +++ b/pkg/server/datastore/cassandra/bundles.go @@ -0,0 +1,615 @@ +package cassandra + +import ( + "context" + "errors" + "fmt" + "time" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + "github.com/spiffe/spire/pkg/common/bundleutil" + "github.com/spiffe/spire/proto/spire/common" + "github.com/tjons/cassandra-toolbox/qb" + "github.com/tjons/cassandra-toolbox/qb/pages" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +func (p *Plugin) AppendBundle(ctx context.Context, req *datastorev1.AppendBundleRequest) (*datastorev1.AppendBundleResponse, error) { + if req == nil || req.Bundle == nil { + return nil, errors.New("missing bundle in request") + } + + existingBundle, err := p.fetchBundle(ctx, req.Bundle.TrustDomainId) + if err != nil { + return nil, err + } + + if existingBundle == nil { + createResp, err := p.createBundle(ctx, req.Bundle) + if err != nil { + return nil, err + } + return &datastorev1.AppendBundleResponse{ + Bundle: createResp, + }, nil + } + + commonExistingBundle, err := dataToBundle(existingBundle.Data) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal existing bundle: %w", err) + } + + commonNewBundle, err := dataToBundle(req.Bundle.Data) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal new bundle: %w", err) + } + + bundle, changed := bundleutil.MergeBundles(commonExistingBundle, commonNewBundle) + if changed { + bundle.SequenceNumber++ + newModel, err := bundleToModel(bundle) + if err != nil { + return nil, err + } + + saveQuery := qb.NewUpdate(). + Table("bundles"). + Set("data", newModel.Data). + Set("updated_at", qb.CqlFunction("toTimestamp(now())")). + Where("trust_domain", qb.Equals(newModel.TrustDomainId)) + + if err = p.db.WriteQuery(saveQuery).ExecContext(ctx); err != nil { + return nil, newWrappedCassandraError(err) + } + } + + commonMergedBundle, err := bundleToModel(bundle) + if err != nil { + return nil, err + } + return &datastorev1.AppendBundleResponse{ + Bundle: commonMergedBundle, + }, nil +} + +func (p *Plugin) CountBundles(ctx context.Context, _ *datastorev1.CountBundlesRequest) (*datastorev1.CountBundlesResponse, error) { + countQuery := qb.NewSelect(). + Column("COUNT(*)"). + From("bundles") + + countQ, _ := countQuery.Build() + var count int32 + + execQuery := p.db.session.Query(countQ).Consistency(p.db.cfg.ReadConsistency) + if err := execQuery.ScanContext(ctx, &count); err != nil { + return nil, newWrappedCassandraError(err) + } + + return &datastorev1.CountBundlesResponse{ + Count: count, + }, nil +} + +func (p *Plugin) CreateBundle(ctx context.Context, req *datastorev1.CreateBundleRequest) (*datastorev1.CreateBundleResponse, error) { + if req.GetBundle() == nil { + return nil, errors.New("missing bundle in request") + } + + exists, err := p.bundleExistsForTrustDomain(ctx, req.Bundle.TrustDomainId) + if err != nil { + return nil, newWrappedCassandraError(err) + } + if exists { + return nil, status.Error(codes.AlreadyExists, "bundle with that trust domain ID already exists") + } + + bundle, err := p.createBundle(ctx, req.Bundle) + if err != nil { + return nil, err + } + + return &datastorev1.CreateBundleResponse{ + Bundle: bundle, + }, nil +} + +func (p *Plugin) DeleteBundle(ctx context.Context, req *datastorev1.DeleteBundleRequest) (*datastorev1.DeleteBundleResponse, error) { + if req.GetTrustDomain() == "" { + return nil, errors.New("missing trust domain in request") + } + + trustDomain := req.GetTrustDomain() + + exists, err := p.bundleExistsForTrustDomain(ctx, trustDomain) + if err != nil { + return nil, newWrappedCassandraError(err) + } + if !exists { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + + b := p.db.session.Batch(gocql.LoggedBatch) + federatedEntries, err := p.findFederatedBundleEntries(ctx, trustDomain) + if err != nil { + return nil, newWrappedCassandraError(err) + } + + switch req.GetMode() { + case datastorev1.DeleteMode_DELETE_MODE_DELETE: + for _, fe := range federatedEntries { + deleteAssociatedQuery := qb.NewDelete(). + From("registered_entries"). + Where("entry_id", qb.Equals(fe.EntryID)) + deleteAssociatedQ, _ := deleteAssociatedQuery.Build() + + b.Entries = append(b.Entries, gocql.BatchEntry{ + Stmt: deleteAssociatedQ, + Idempotent: true, + Args: deleteAssociatedQuery.QueryValues(), + }) + } + case datastorev1.DeleteMode_DELETE_MODE_DISSOCIATE: + for _, fe := range federatedEntries { + // remove this trust domain from the federated trust domains lists + updatedTrustDomainsFull := make([]string, 0, len(fe.TrustDomainsFull)) + for _, td := range fe.TrustDomainsFull { + if td != trustDomain { + updatedTrustDomainsFull = append(updatedTrustDomainsFull, td) + } + } + + dissociativeUpdateQuery := qb.NewUpdate(). + Table("registered_entries"). + Set("federated_trust_domains_full", updatedTrustDomainsFull). + Set("federated_trust_domains", updatedTrustDomainsFull). + Where("entry_id", qb.Equals(fe.EntryID)) + dissociativeUpdateQ, _ := dissociativeUpdateQuery.Build() + + b.Entries = append(b.Entries, gocql.BatchEntry{ + Stmt: dissociativeUpdateQ, + Idempotent: true, + Args: dissociativeUpdateQuery.QueryValues(), + }) + + deleteEntryRowQuery := qb.NewDelete(). + From("registered_entries"). + Where("entry_id", qb.Equals(fe.EntryID)). + Where("unrolled_selector_type_val", qb.Equals("")). + Where("unrolled_ftd", qb.Equals(trustDomain)) + deleteEntryRowQ, _ := deleteEntryRowQuery.Build() + + b.Entries = append(b.Entries, gocql.BatchEntry{ + Stmt: deleteEntryRowQ, + Idempotent: true, + Args: deleteEntryRowQuery.QueryValues(), + }) + } + case datastorev1.DeleteMode_DELETE_MODE_RESTRICT: + if len(federatedEntries) > 0 { + return nil, status.Error( + codes.FailedPrecondition, + newCassandraError( + "cannot delete bundle; federated with %d registration entries", + len(federatedEntries), + ).Error(), + ) + } + } + + deleteQuery := qb.NewDelete(). + From("bundles"). + Where("trust_domain", qb.Equals(trustDomain)) + + deleteQ, _ := deleteQuery.Build() + + b.Entries = append(b.Entries, gocql.BatchEntry{ + Stmt: deleteQ, + Idempotent: true, + Args: deleteQuery.QueryValues(), + }) + + if err = b.ExecContext(ctx); err != nil { + return nil, newWrappedCassandraError(err) + } + + return &datastorev1.DeleteBundleResponse{}, nil +} + +type federatedEntryRecord struct { + EntryID string + UnrolledTrustDomain string + TrustDomainsFull []string + TrustDomainsUnrolled []string +} + +func (p *Plugin) findFederatedBundleEntries(ctx context.Context, trustDomain string) ([]*federatedEntryRecord, error) { + federatedEntriesQuery := qb.NewSelect(). + Column("entry_id"). + Column("unrolled_ftd"). + Column("federated_trust_domains_full"). + Column("federated_trust_domains"). + From("registered_entries"). + Where("unrolled_ftd", qb.Equals(trustDomain)). + AllowFiltering() + + iter := p.db.ReadQuery(federatedEntriesQuery).IterContext(ctx) + scanner := iter.Scanner() + + entries := []*federatedEntryRecord{} + for scanner.Next() { + entry := &federatedEntryRecord{} + if err := scanner.Scan( + &entry.EntryID, + &entry.UnrolledTrustDomain, + &entry.TrustDomainsFull, + &entry.TrustDomainsUnrolled, + ); err != nil { + return nil, newWrappedCassandraError(err) + } + entries = append(entries, entry) + } + if err := scanner.Err(); err != nil { + return nil, newWrappedCassandraError(err) + } + + return entries, nil +} + +func (p *Plugin) FetchBundle(ctx context.Context, req *datastorev1.FetchBundleRequest) (*datastorev1.FetchBundleResponse, error) { + bundle, err := p.fetchBundle(ctx, req.GetTrustDomain()) + if err != nil { + return nil, err + } + + return &datastorev1.FetchBundleResponse{ + Bundle: bundle, + }, nil +} + +// why duplicate with the public wrapper methods? originally, I _hated_ this from the +// sqlstore implementation, but now I don't think it's so bad, because parameter tuning +// etc (contexts, timeouts, consistency levels) will be easier to accomplish outside the +// interface +func (p *Plugin) fetchBundle(ctx context.Context, trustDomainID string) (*datastorev1.Bundle, error) { + q := qb.NewSelect(). + Column("data"). + Column("created_at"). + Column("updated_at"). + From("bundles"). + Where("trust_domain", qb.Equals(trustDomainID)) + + var ( + data []byte + createdAt time.Time + updatedAt time.Time + ) + query := p.db.ReadQuery(q) + if err := query.ScanContext(ctx, &data, &createdAt, &updatedAt); err != nil { + if errors.Is(err, gocql.ErrNotFound) { + // The existing datastore implementation does not return an error when no results are found + return nil, nil + } + + return nil, fmt.Errorf("Error scanning from bundles: %w", err) + } + + if len(data) == 0 { + return nil, fmt.Errorf("No bundle found with trust domain ID %s", trustDomainID) + } + + return &datastorev1.Bundle{ + TrustDomainId: trustDomainID, + Data: data, + CreatedAt: createdAt.Unix(), + UpdatedAt: updatedAt.Unix(), + }, nil +} + +func dataToBundle(data []byte) (*common.Bundle, error) { + bundle := new(common.Bundle) + if err := proto.Unmarshal(data, bundle); err != nil { + return nil, err + } + + return bundle, nil +} + +func (p *Plugin) ListBundles(ctx context.Context, req *datastorev1.ListBundlesRequest) (*datastorev1.ListBundlesResponse, error) { + pager := pages.NewQueryPaginator(req.GetPagination() != nil, req.GetPagination().GetPageSize(), req.GetPagination().GetPageToken()) + if err := pager.Validate(); err != nil { + return nil, err + } + + q := qb.NewSelect(). + Distinct(). + From("bundles"). + Column("trust_domain"). + Column("data") + + selectStmt, _ := q.Build() + + resp := &datastorev1.ListBundlesResponse{ + Bundles: make([]*datastorev1.Bundle, 0), + } + query := p.db.session.Query(selectStmt).Consistency(gocql.Serial) + pager.BindToQuery(query) + + iter := query.IterContext(ctx) + pager.ForIter(iter) + scanner := iter.Scanner() + + for scanner.Next() { + b := new(datastorev1.Bundle) + if err := scanner.Scan(&b.TrustDomainId, &b.Data); err != nil { + return nil, newWrappedCassandraError(err) + } + + resp.Bundles = append(resp.Bundles, b) + } + if err := scanner.Err(); err != nil { + return nil, newWrappedCassandraError(err) + } + + resp.Pagination = responsePaginationFromPager(pager) + + return resp, nil +} + +func (p *Plugin) PruneBundle(ctx context.Context, req *datastorev1.PruneBundleRequest) (*datastorev1.PruneBundleResponse, error) { + if req == nil || req.GetTrustDomain() == "" { + return nil, errors.New("missing trust domain ID in request") + } + + trustDomainID := req.GetTrustDomain() + expiresBefore := time.Unix(int64(req.GetExpiresBefore()), 0) + + currentBundle, err := p.fetchBundle(ctx, trustDomainID) + if err != nil { + return nil, fmt.Errorf("unable to fetch current bundle: %w", err) + } + + if currentBundle == nil { + return nil, nil + } + + commonCurrentBundle, err := dataToBundle(currentBundle.Data) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal current bundle: %w", err) + } + + newBundle, changed, err := bundleutil.PruneBundle(commonCurrentBundle, expiresBefore, p.log) + if err != nil { + return nil, fmt.Errorf("prune failed: %w", err) + } + + if changed { + newBundle.SequenceNumber = commonCurrentBundle.SequenceNumber + 1 + modelNewBundle, err := bundleToModel(newBundle) + if err != nil { + return nil, fmt.Errorf("unable to convert pruned bundle to model: %w", err) + } + + if _, err = p.updateBundle(ctx, &datastorev1.UpdateBundleRequest{ + Bundle: modelNewBundle, + Mask: AllTrueBundleMask, + }); err != nil { + return nil, fmt.Errorf("unable to write new bundle: %w", err) + } + } + + return &datastorev1.PruneBundleResponse{Changed: changed}, nil +} + +func (p *Plugin) bundleExistsForTrustDomain(ctx context.Context, trustDomainID string) (bool, error) { + var count int + existsQ := ` + SELECT COUNT(*) + FROM bundles + WHERE trust_domain = ?` + + query := p.db.session.Query(existsQ, trustDomainID).Consistency(gocql.Serial) + if err := query.ScanContext(ctx, &count); err != nil { + if errors.Is(err, gocql.ErrNotFound) { + return false, nil + } + + return false, err + } + + return count > 0, nil +} + +func (p *Plugin) SetBundle(ctx context.Context, req *datastorev1.SetBundleRequest) (*datastorev1.SetBundleResponse, error) { + if req == nil || req.Bundle == nil { + return nil, errors.New("missing bundle in request") + } + + exists, err := p.bundleExistsForTrustDomain(ctx, req.Bundle.TrustDomainId) + if err != nil { + return nil, newWrappedCassandraError(err) + } + + if !exists { + bundle, err := p.createBundle(ctx, req.Bundle) + if err != nil { + return nil, err + } + return &datastorev1.SetBundleResponse{Bundle: bundle}, nil + } + + bundle, err := p.updateBundle(ctx, &datastorev1.UpdateBundleRequest{ + Bundle: req.Bundle, + Mask: AllTrueBundleMask, + }) + if err != nil { + return nil, err + } + return &datastorev1.SetBundleResponse{Bundle: bundle.Bundle}, nil +} + +func (p *Plugin) createBundle(ctx context.Context, newBundle *datastorev1.Bundle) (*datastorev1.Bundle, error) { + // The Bundle will always have a row set with an empty federated_entry_id. + // This allows federation relationships to come and go without impacting the bundle data itself, + // and simplifies query patterns. + createQ := ` + INSERT INTO bundles (created_at, updated_at, trust_domain, data, federated_entry_id) + VALUES (toTimestamp(now()), toTimestamp(now()), ?, ?, '') + ` + query := p.db.session.Query(createQ, newBundle.TrustDomainId, newBundle.Data).Consistency(p.db.cfg.WriteConsistency) + + err := query.ExecContext(ctx) + if err != nil { + return nil, newWrappedCassandraError(err) + } + + return newBundle, nil +} + +func (p *Plugin) updateBundle(ctx context.Context, req *datastorev1.UpdateBundleRequest) (*datastorev1.UpdateBundleResponse, error) { + existingModel := &datastorev1.Bundle{} + readQ := ` + SELECT DISTINCT created_at, updated_at, trust_domain, data + FROM bundles WHERE trust_domain = ? + ` + + query := p.db.session.Query(readQ, req.Bundle.TrustDomainId).Consistency(gocql.Serial) + if err := query.ScanContext( + ctx, + &existingModel.CreatedAt, + &existingModel.UpdatedAt, + &existingModel.TrustDomainId, + &existingModel.Data, + ); err != nil { + if errors.Is(err, gocql.ErrNotFound) { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + + return nil, newCassandraError("could not read existing bundle: %s", err.Error()) + } + + newBundle, err := dataToBundle(req.Bundle.Data) + if err != nil { + return nil, newWrappedCassandraError(fmt.Errorf("unable to unmarshal new bundle: %w", err)) + } + existingBundle, err := dataToBundle(existingModel.Data) + if err != nil { + return nil, newWrappedCassandraError(fmt.Errorf("unable to unmarshal existing bundle: %w", err)) + } + + inputMask := req.GetMask() + if req.GetMask() == nil { + inputMask = AllTrueBundleMask + } + + if inputMask.RefreshHint { + existingBundle.RefreshHint = newBundle.RefreshHint + } + + if inputMask.RootCas { + existingBundle.RootCas = newBundle.RootCas + } + + if inputMask.JwtSigningKeys { + existingBundle.JwtSigningKeys = newBundle.JwtSigningKeys + } + + if inputMask.SequenceNumber { + existingBundle.SequenceNumber = newBundle.SequenceNumber + } + + if inputMask.WitSigningKeys { + existingBundle.WitSigningKeys = newBundle.WitSigningKeys + } + + // TODO(tjons): why do we not check X509TaintedKeys in the mask? + + modelDataToSave, err := bundleToModel(existingBundle) + if err != nil { + return nil, newWrappedCassandraError(err) + } + + updateQ := ` + UPDATE bundles + SET updated_at = toTimestamp(now()), + data = ? + WHERE trust_domain = ? + ` + query = p.db.session.Query(updateQ, modelDataToSave.Data, modelDataToSave.TrustDomainId).Consistency(p.db.cfg.WriteConsistency) + if err = query.ExecContext(ctx); err != nil { + return nil, newWrappedCassandraError(err) + } + + finalBundle, err := p.fetchBundle(ctx, req.GetBundle().GetTrustDomainId()) + if err != nil { + return nil, newWrappedCassandraError(err) + } + + return &datastorev1.UpdateBundleResponse{Bundle: finalBundle}, nil +} + +var AllTrueBundleMask = &datastorev1.BundleMask{ + RootCas: true, + JwtSigningKeys: true, + RefreshHint: true, + SequenceNumber: true, + X509TaintedKeys: true, + WitSigningKeys: true, +} + +func applyBundleMask(model *datastorev1.Bundle, newBundle *common.Bundle, inputMask *datastorev1.BundleMask) ([]byte, *common.Bundle, error) { + bundle, err := dataToBundle(model.Data) + if err != nil { + return nil, nil, err + } + + if inputMask == nil { + inputMask = AllTrueBundleMask + } + + if inputMask.RefreshHint { + bundle.RefreshHint = newBundle.RefreshHint + } + + if inputMask.RootCas { + bundle.RootCas = newBundle.RootCas + } + + if inputMask.JwtSigningKeys { + bundle.JwtSigningKeys = newBundle.JwtSigningKeys + } + + if inputMask.SequenceNumber { + bundle.SequenceNumber = newBundle.SequenceNumber + } + + newModel, err := bundleToModel(bundle) + if err != nil { + return nil, nil, err + } + + return newModel.Data, bundle, nil +} + +func (p *Plugin) UpdateBundle(ctx context.Context, req *datastorev1.UpdateBundleRequest) (*datastorev1.UpdateBundleResponse, error) { + return p.updateBundle(ctx, req) +} + +func bundleToModel(pb *common.Bundle) (*datastorev1.Bundle, error) { + if pb == nil { + return nil, newCassandraError("missing bundle in request") + } + + data, err := proto.Marshal(pb) + if err != nil { + return nil, newWrappedCassandraError(err) + } + + return &datastorev1.Bundle{ + TrustDomainId: pb.TrustDomainId, + Data: data, + }, nil +} diff --git a/pkg/server/datastore/cassandra/ca_journals.go b/pkg/server/datastore/cassandra/ca_journals.go new file mode 100644 index 0000000000..187817e7ac --- /dev/null +++ b/pkg/server/datastore/cassandra/ca_journals.go @@ -0,0 +1,261 @@ +package cassandra + +import ( + "context" + "errors" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + "github.com/sirupsen/logrus" + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + "github.com/spiffe/spire/pkg/common/telemetry" + "github.com/spiffe/spire/proto/private/server/journal" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +type CAJournal struct { + ID uint + ActiveX509AuthorityID string + JournalData []byte +} + +func (p *Plugin) nextCAJournalID(ctx context.Context) (uint, error) { + createQ := `SELECT MAX(id) FROM ca_journals ALLOW FILTERING` + var maxID uint + if err := p.db.session.Query(createQ).Consistency(p.db.cfg.WriteConsistency).ScanContext(ctx, &maxID); err != nil { + return 0, err + } + return maxID + 1, nil +} + +func (p *Plugin) SetCAJournal(ctx context.Context, req *datastorev1.SetCAJournalRequest) (*datastorev1.SetCAJournalResponse, error) { + if req == nil || req.GetJournal() == nil { + return nil, status.Error(codes.InvalidArgument, "ca journal is required") + } + caJournal := req.GetJournal() + + if err := validateCAJournal(caJournal); err != nil { + return nil, err + } + + var ( + journal *datastorev1.CAJournal + err error + ) + if caJournal.Id == 0 { + journal, err = p.createCAJournal(ctx, caJournal) + } else { + journal, err = p.updateCAJournal(ctx, caJournal) + } + + return &datastorev1.SetCAJournalResponse{ + Journal: journal, + }, err +} + +func (p *Plugin) updateCAJournal(ctx context.Context, caJournal *datastorev1.CAJournal) (*datastorev1.CAJournal, error) { + updateQ := `UPDATE ca_journals SET + active_x509_authority_id = ?, + data = ?, + updated_at = toTimestamp(now()) + WHERE id = ? IF EXISTS` + + res := make(map[string]any) + applied, err := p.db.session.Query(updateQ, + caJournal.GetActiveX509AuthorityId(), + caJournal.GetData(), + caJournal.GetId(), + ).Consistency(p.db.cfg.WriteConsistency).MapScanCAS(res) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to update CA journal: %v", err) + } + + if !applied { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + + return p.fetchCAJournalByID(ctx, caJournal.GetId()) +} + +func (p *Plugin) createCAJournal(ctx context.Context, caJournal *datastorev1.CAJournal) (*datastorev1.CAJournal, error) { + nextId, err := p.nextCAJournalID(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get next CA journal ID: %v", err) + } + + createQ := `INSERT INTO ca_journals ( + id, + active_x509_authority_id, + data, + created_at, + updated_at + ) VALUES (?, ?, ?, toTimestamp(now()), toTimestamp(now())) IF NOT EXISTS` + + if err := p.db.session.Query(createQ, + nextId, + caJournal.GetActiveX509AuthorityId(), + caJournal.GetData(), + ).Consistency(p.db.cfg.WriteConsistency).ExecContext(ctx); err != nil { + return nil, status.Errorf(codes.Internal, "failed to insert CA journal: %v", err) + } + + caJournal.Id = uint64(nextId) + + return caJournal, nil +} + +func (p *Plugin) fetchCAJournalByID(ctx context.Context, id uint64) (*datastorev1.CAJournal, error) { + findQ := `SELECT + id, + active_x509_authority_id, + data + FROM ca_journals + WHERE id = ?` + + var caJournal datastorev1.CAJournal + if err := p.db.session.Query(findQ, id).Consistency(p.db.cfg.ReadConsistency).ScanContext( + ctx, + &caJournal.Id, + &caJournal.ActiveX509AuthorityId, + &caJournal.Data, + ); err != nil { + return nil, err + } + + return &caJournal, nil +} + +func (p *Plugin) fetchCAJournalByActiveX509AuthorityID(ctx context.Context, activeX509AuthorityID string) (*datastorev1.CAJournal, error) { + findQ := `SELECT + id, + active_x509_authority_id, + data + FROM ca_journals + WHERE active_x509_authority_id = ? LIMIT 1` + + var caJournal datastorev1.CAJournal + if err := p.db.session.Query(findQ, activeX509AuthorityID).Consistency(p.db.cfg.ReadConsistency).ScanContext( + ctx, + &caJournal.Id, + &caJournal.ActiveX509AuthorityId, + &caJournal.Data, + ); err != nil { + return nil, err + } + + return &caJournal, nil +} + +func (p *Plugin) FetchCAJournal(ctx context.Context, req *datastorev1.FetchCAJournalRequest) (*datastorev1.FetchCAJournalResponse, error) { + if req.GetActiveX509AuthorityId() == "" { + return nil, status.Error(codes.InvalidArgument, "active X509 authority ID is required") + } + + j, err := p.fetchCAJournalByActiveX509AuthorityID(ctx, req.GetActiveX509AuthorityId()) + if err != nil { + if errors.Is(err, gocql.ErrNotFound) { + return nil, nil + } + return nil, err + } + + return &datastorev1.FetchCAJournalResponse{ + Journal: j, + }, nil +} + +func (p *Plugin) PruneCAJournals(ctx context.Context, req *datastorev1.PruneCAJournalsRequest) (*datastorev1.PruneCAJournalsResponse, error) { + journals, err := p.listCAJournals(ctx) + if err != nil { + return nil, err + } + +checkAuthorities: + for _, model := range journals { + entries := new(journal.Entries) + if err := proto.Unmarshal(model.Data, entries); err != nil { + return nil, status.Errorf(codes.Internal, "unable to unmarshal entries from CA journal record: %v", err) + } + + for _, x509CA := range entries.X509CAs { + if x509CA.NotAfter > int64(req.GetExpiresBefore()) { + continue checkAuthorities + } + } + + for _, jwtKey := range entries.JwtKeys { + if jwtKey.NotAfter > int64(req.GetExpiresBefore()) { + continue checkAuthorities + } + } + + if err := p.deleteCAJournal(ctx, model.Id); err != nil { + return nil, status.Errorf(codes.Internal, "failed to delete CA journal: %v", err) + } + + p.log.WithFields(logrus.Fields{ + telemetry.CAJournalID: model.Id, + }).Info("Pruned stale CA journal record") + } + + return &datastorev1.PruneCAJournalsResponse{}, nil +} + +func (p *Plugin) deleteCAJournal(ctx context.Context, id uint64) error { + deleteQ := `DELETE FROM ca_journals WHERE id = ? IF EXISTS` + + if err := p.db.session.Query(deleteQ, id).Consistency(p.db.cfg.WriteConsistency).ExecContext(ctx); err != nil { + return status.Errorf(codes.Internal, "failed to delete CA journal: %v", err) + } + + return nil +} + +func (p *Plugin) ListCAJournals(ctx context.Context, req *datastorev1.ListCAJournalsRequest) (*datastorev1.ListCAJournalsResponse, error) { + journals, err := p.listCAJournals(ctx) + if err != nil { + return nil, err + } + + return &datastorev1.ListCAJournalsResponse{ + Journals: journals, + }, nil +} + +func (p *Plugin) listCAJournals(ctx context.Context) ([]*datastorev1.CAJournal, error) { + listQ := `SELECT + id, + active_x509_authority_id, + data + FROM ca_journals` + + var caJournals []*datastorev1.CAJournal + scanner := p.db.session.Query(listQ).Consistency(p.db.cfg.ReadConsistency).IterContext(ctx).Scanner() + for scanner.Next() { + caJournal := new(datastorev1.CAJournal) + + if err := scanner.Scan( + &caJournal.Id, + &caJournal.ActiveX509AuthorityId, + &caJournal.Data, + ); err != nil { + return nil, status.Errorf(codes.Internal, "failed to scan CA journal: %v", err) + } + caJournals = append(caJournals, caJournal) + } + if err := scanner.Err(); err != nil { + return nil, status.Errorf(codes.Internal, "failed to iterate CA journals: %v", err) + } + + return caJournals, nil +} + +// TODO(tjons): copied from pkg/server/datastore/sqlstore/sqlstore.go:4935. unify this. +func validateCAJournal(caJournal *datastorev1.CAJournal) error { + if caJournal == nil { + return status.Error(codes.InvalidArgument, "ca journal is required") + } + + return nil +} diff --git a/pkg/server/datastore/cassandra/config.go b/pkg/server/datastore/cassandra/config.go new file mode 100644 index 0000000000..896a70ded4 --- /dev/null +++ b/pkg/server/datastore/cassandra/config.go @@ -0,0 +1,271 @@ +package cassandra + +import ( + "fmt" + "time" + + gocql "github.com/apache/cassandra-gocql-driver/v2" +) + +const initialConnectionBackoff = 1500 * time.Millisecond + +// Configuration is the user-provided Configuration for the Cassandra datastore plugin. +// This type is used to unmarshal the HCL Configuration provided by the user, and is then transformed +// into a runtimeConfiguration which validates and defaults the Configuration values for use at runtime. +// Field-level behavior is documented in the `Configuration` type. +type Configuration struct { + // The cassandra cluster members. + // DNS names or IP addresses are both acceptable. At least one host is required. + // Suggest using the Cassandra seed nodes here, as the client with automatically + // discover the rest of the cluster from there, but there is no limit to the number + // of hosts that can be provided here. Each host can optionally include a port number, + // which will default to 9042 if not specified. + Hosts []string `hcl:"hosts"` + + // The keyspace name to use for storage. If the keyspace does not exist, the plugin will create it. + // Generally this is fine for development and testing, but care should be taken when using this in production, + // as you will likely want to tune the replication strategy and factor to your needs, which the plugin will not + // mutate once the keyspace is initially created. + Keyspace string `hcl:"keyspace"` + + // The replication strategy to use for the keyspace, if the plugin is creating it. This can be either + // "SimpleStrategy" or "NetworkTopologyStrategy". It is highly discouraged to run "SimpleStrategy" in production, + // as data will inevitably be lost in various failure modes. + ReplicationStrategy string `hcl:"replication_strategy"` + + // If using "SimpleStrategy" for replication, this will be the replication factor used for the keyspace. If not + // specified, the plugin will default to a replication factor of 1, which is not suitable for production. + SimpleStrategyReplicationFactor int `hcl:"simple_strategy_replication_factor"` + + // If using "NetworkTopologyStrategy" for replication, this will be a map of datacenter names to replication factors + // used for the keyspace. This field is required if "NetworkTopologyStrategy" is used for replication. + NetworkTopologyStrategyReplicationFactors map[string]int `hcl:"network_topology_strategy_replication_factors"` + + // Username to use when authenticating to Cassandra. If not specified, the plugin will attempt to connect + // without authenticating. + Username string `hcl:"username"` + + // Password to use when authenticating to Cassandra. If not specified, the plugin will attempt to connect + // without authenticating. + Password string `hcl:"password"` + + // TODO(tjons): should query routing be configurable? + + // The path to the root CA certificate for TLS connections to Cassandra. If this is not specified, TLS will not be + // used. If this is specified, the plugin will require TLS connections to Cassandra and will verify the Cassandra + // Node certificate against the root CA. + // + // Setting client_key_path and client_cert_path can also affect the behavior of TLS connections. + RootCAPath string `hcl:"root_ca_path"` + + // The path to the client key for mTLS connections to Cassandra. If this is specified, root_ca_path and + // client_cert_path must also be specified. When configured, the plugin will require mTLS connections to Cassandra, + // using the provided client key and certificate for authentication. + ClientKeyPath string `hcl:"client_key_path"` + + // The path to the client certificate for mTLS connections to Cassandra. If this is specified, client_key_path and + // root_ca_path must also be specified. When configured, the plugin will require mTLS connections to Cassandra, + // using the provided client key and certificate for authentication. + ClientCertPath string `hcl:"client_cert_path"` + + // The number of connections to keep in the connection pool for each host in the Cassandra cluster. + // If not specified, the plugin default of 10 connections per host will be used. It is highly recommended + // that you tune this value based on the expected load and performance characteristics of your environment, + // as the default may not be suitable for production use. + NumConns int `hcl:"num_conns"` + + // The maximum number of attempts to connect to the Cassandra cluster before giving up and returning an error. + // The plugin will attempt to connect to the cluster when it starts up, waiting up to the duration specified by + // ConnectTimeout for each attempt, and will backoff for 1500ms between attempts. If not specified, the plugin will + // attempt to connect 5 times before giving up. + MaxConnectionAttempts int `hcl:"max_connection_attempts"` + + // The duration in milliseconds to wait for a connection to the Cassandra cluster before timing out and retrying. + // If not specified, the plugin will wait 1000ms before timing out and retrying. + ConnectTimeout time.Duration `hcl:"connect_timeout_ms"` + + // The duration in milliseconds to wait for a read operation to complete before timing out. + // If not specified, the plugin will wait 1000ms before timing out. + ReadTimeout time.Duration `hcl:"read_timeout_ms"` + + // The duration in milliseconds to wait for a write operation to complete before timing out. + // If not specified, the plugin will wait 1000ms before timing out. + WriteTimeout time.Duration `hcl:"write_timeout_ms"` + + // Whether to skip running database migrations on startup. This will be false by default, and is suitable for development + // and testing. In production, you may want to run migrations separately and set this to true to avoid any + // potential issues with running migrations on startup, such as multiple instances of the plugin attempting to run + // the same migrations at the same time. In any case, the plugin will log any pending migrations on startup, + // and refuse to start if there are pending migrations and the plugin is configured not to run them. + SkipMigrations bool `hcl:"skip_migrations"` + + // To determine whether or not there are more results to be paginated through, the Cassandra plugin needs to "peek" + // at the next page of results by fetching a single item from it. This can be unnecessary drag on performance, so + // this option allows you to disable that behavior. If you disable pagination peeking, the plugin will generally + // perform better when paginating through large result sets, but will not accurately determine whether or not the + // current page is the last page, and will always assume that there is another page of results after the current one. + DisablePaginationPeeking bool `hcl:"disable_pagination_peeking"` + + // Default consistency levels for read operations. Acceptable values are: + // "ONE", "LOCAL_QUORUM", "EACH_QUORUM", "QUORUM", or "ALL". If not specified, the default + // consistency level for reads will be "LOCAL_QUORUM". For more information + // on Cassandra consistency levels, see + // https://cassandra.apache.org/doc/latest/cassandra/architecture/dynamo.html#tunable-consistency. + ReadConsistency string `hcl:"read_consistency"` + + // Default consistency levels for write operations. Acceptable values are: + // - "ONE" + // - "LOCAL_QUORUM" + // - "QUORUM" + // - "EACH_QUORUM" + // - "ALL" + // If not specified, the default consistency level for writes will be "LOCAL_QUORUM". + // For more information on Cassandra consistency levels, see + // https://cassandra.apache.org/doc/latest/cassandra/architecture/dynamo.html#tunable-consistency. + WriteConsistency string `hcl:"write_consistency"` + + // Used to control the log level of the underlying Cassandra database driver, not the plugin itself. + // Acceptable values are "DEBUG", "INFO", "WARN", "ERROR", and "OFF". + // If not specified, the driver will use its default log level. + DriverLogLevel string `hcl:"driver_log_level"` +} + +func validateConsistencyLevel(level string) error { + switch level { + case "ONE", "LOCAL_QUORUM", "QUORUM", "EACH_QUORUM", "ALL": + return nil + default: + return fmt.Errorf("invalid consistency level: %s", level) + } +} + +type ReplicationStrategy string + +const ( + SimpleStrategy ReplicationStrategy = "SimpleStrategy" + NetworkTopologyStrategy ReplicationStrategy = "NetworkTopologyStrategy" +) + +// runtimeConfiguration is the parsed, validated and defaulted configuration that is used at runtime. +// It's derived from the user-provided `configuration` type, but with all fields set to their final values. +// Field-level behavior is documented in the `configuration` type. +type runtimeConfiguration struct { + ReadConsistency gocql.Consistency + WriteConsistency gocql.Consistency + Keyspace string + Hosts []string + DisablePaginationPeeking bool + ConnectTimeout time.Duration + ReadTimeout time.Duration + WriteTimeout time.Duration + MaxConnectionAttempts int + RunMigrations bool + ReplicationStrategy ReplicationStrategy + SimpleStrategyReplicationFactor int + NetworkTopologyStrategyReplicationFactors map[string]int + Username string + Password string + DriverLogLevel driverLogLevel + TLSConfig *tlsConfig + NumConns int +} + +type tlsConfig struct { + RootCAPath string + ClientKeyPath string + ClientCertPath string +} + +func (t tlsConfig) RequireMTLS() bool { + return t.RequireTLS() && t.ClientKeyPath != "" && t.ClientCertPath != "" +} + +func (t tlsConfig) RequireTLS() bool { + return t.RootCAPath != "" +} + +func (r *runtimeConfiguration) FromUserConfig(cfg *Configuration) error { + r.Hosts = cfg.Hosts + r.Keyspace = cfg.Keyspace + r.DisablePaginationPeeking = cfg.DisablePaginationPeeking + r.ConnectTimeout = time.Duration(cfg.ConnectTimeout) * time.Millisecond + r.ReadTimeout = time.Duration(cfg.ReadTimeout) * time.Millisecond + r.WriteTimeout = time.Duration(cfg.WriteTimeout) * time.Millisecond + r.MaxConnectionAttempts = cfg.MaxConnectionAttempts + r.RunMigrations = !cfg.SkipMigrations + r.SimpleStrategyReplicationFactor = cfg.SimpleStrategyReplicationFactor + r.NetworkTopologyStrategyReplicationFactors = cfg.NetworkTopologyStrategyReplicationFactors + r.Username = cfg.Username + r.Password = cfg.Password + r.DriverLogLevel = driverLogLevel(cfg.DriverLogLevel) // TODO(tjons): validate that this is a correct level + r.TLSConfig = &tlsConfig{ + RootCAPath: cfg.RootCAPath, + ClientKeyPath: cfg.ClientKeyPath, + ClientCertPath: cfg.ClientCertPath, + } + r.NumConns = cfg.NumConns + if r.NumConns == 0 { + r.NumConns = 10 + } + + if cfg.ReplicationStrategy == "" { + r.ReplicationStrategy = SimpleStrategy + } else { + switch cfg.ReplicationStrategy { + case "SimpleStrategy": + r.ReplicationStrategy = SimpleStrategy + case "NetworkTopologyStrategy": + r.ReplicationStrategy = NetworkTopologyStrategy + default: + return fmt.Errorf("invalid replication strategy: %s", cfg.ReplicationStrategy) + } + } + + if cfg.SimpleStrategyReplicationFactor == 0 { + r.SimpleStrategyReplicationFactor = 1 + } + + if cfg.MaxConnectionAttempts == 0 { + r.MaxConnectionAttempts = 5 + } + + if cfg.ConnectTimeout == 0 { + r.ConnectTimeout = 1000 * time.Millisecond + } + + if cfg.ReadTimeout == 0 { + r.ReadTimeout = 1000 * time.Millisecond + } + + if cfg.WriteTimeout == 0 { + r.WriteTimeout = 1000 * time.Millisecond + } + + if cfg.ReadConsistency == "" { + cfg.ReadConsistency = gocql.LocalQuorum.String() + } + if err := validateConsistencyLevel(cfg.ReadConsistency); err != nil { + return fmt.Errorf("invalid read consistency level: %w", err) + } + r.ReadConsistency = gocql.ParseConsistency(cfg.ReadConsistency) + + if cfg.WriteConsistency == "" { + cfg.WriteConsistency = gocql.LocalQuorum.String() + } + if err := validateConsistencyLevel(cfg.WriteConsistency); err != nil { + return fmt.Errorf("invalid write consistency level: %w", err) + } + r.WriteConsistency = gocql.ParseConsistency(cfg.WriteConsistency) + + if r.Username != "" || r.Password != "" { + if r.Username == "" { + return fmt.Errorf("username must be provided if password is provided") + } + + if r.Password == "" { + return fmt.Errorf("password must be provided if username is provided") + } + } + + return nil +} diff --git a/pkg/server/datastore/cassandra/db.go b/pkg/server/datastore/cassandra/db.go new file mode 100644 index 0000000000..01c7fa010a --- /dev/null +++ b/pkg/server/datastore/cassandra/db.go @@ -0,0 +1,147 @@ +package cassandra + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + "github.com/sirupsen/logrus" + "github.com/tjons/cassandra-toolbox/qb" +) + +type cassandraDB struct { + cfg *runtimeConfiguration + rwLock *sync.Mutex + log logrus.FieldLogger + session *gocql.Session +} + +func (c *cassandraDB) WriteQuery(wq qb.QueryBuilder) *gocql.Query { + stmt, _ := wq.Build() + + query := c.session.Query(stmt, wq.QueryValues()...) + query.Consistency(c.cfg.WriteConsistency) + + return query +} + +func (c *cassandraDB) ReadQuery(rq qb.QueryBuilder) *gocql.Query { + stmt, _ := rq.Build() + + query := c.session.Query(stmt, rq.QueryValues()...) + query.Consistency(c.cfg.ReadConsistency) + + return query +} + +func (p *Plugin) openConnections(ctx context.Context, config *runtimeConfiguration) error { + if p.cfg == nil { + return errors.New("configuration not set") + } + + return p.openConnection(ctx, config) +} + +func (p *Plugin) openConnection(ctx context.Context, config *runtimeConfiguration) (err error) { + p.rwLock.Lock() + defer p.rwLock.Unlock() + + if err := p.ensureKeyspaceExists(ctx, config); err != nil { + return err + } + + db := &cassandraDB{ + cfg: config, + log: p.log, + } + db.session, err = p.createSession(config) + if err != nil { + return fmt.Errorf("failed to create Cassandra session: %w", err) + } + p.db = db + + // TODO(tjons): use this to split the execution of the migrations from checking if the migrations need to be run. + // We want to refuse to start if we are not allowed to run migrations and there are pending migrations. + if config.RunMigrations { + p.log.Info("Running Cassandra migrations...") + if err := p.applyMigrations(p.db.session); err != nil { + return err + } + p.log.Info("Cassandra migrations complete.") + } + + return nil +} + +func (p *Plugin) createSession(config *runtimeConfiguration) (*gocql.Session, error) { + clusterConfig := gocql.NewCluster(config.Hosts...) + clusterConfig.ConnectTimeout = config.ConnectTimeout + clusterConfig.NumConns = config.NumConns + clusterConfig.Timeout = config.ReadTimeout + clusterConfig.WriteTimeout = config.WriteTimeout + clusterConfig.Keyspace = config.Keyspace + clusterConfig.Consistency = config.ReadConsistency + clusterConfig.Logger = &wrappedLogger{logger: p.log, level: config.DriverLogLevel} + + if config.Username != "" && config.Password != "" { + clusterConfig.Authenticator = gocql.PasswordAuthenticator{ + Username: config.Username, + Password: config.Password, + } + } else { + p.log.Warn("No authentication configured for Cassandra. This is not recommended for production environments.") + } + + if config.TLSConfig.RequireMTLS() { + // Verify that the files can be read before attempting to use them for TLS configuration, to fail fast if there are any issues with the provided paths or files. + clientCert, err := os.ReadFile(config.TLSConfig.ClientCertPath) + if err != nil { + return nil, fmt.Errorf("unable to read client certificate: %w", err) + } + if len(clientCert) == 0 { + return nil, fmt.Errorf("client certificate file is empty: %s", config.TLSConfig.ClientCertPath) + } + + clientKey, err := os.ReadFile(config.TLSConfig.ClientKeyPath) + if err != nil { + return nil, fmt.Errorf("unable to read client key: %w", err) + } + if len(clientKey) == 0 { + return nil, fmt.Errorf("client key file is empty: %s", config.TLSConfig.ClientKeyPath) + } + + rootCA, err := os.ReadFile(config.TLSConfig.RootCAPath) + if err != nil { + return nil, fmt.Errorf("unable to read root CA certificate: %w", err) + } + if len(rootCA) == 0 { + return nil, fmt.Errorf("root CA certificate file is empty: %s", config.TLSConfig.RootCAPath) + } + + clusterConfig.SslOpts = &gocql.SslOptions{ + EnableHostVerification: true, + CertPath: config.TLSConfig.ClientCertPath, + KeyPath: config.TLSConfig.ClientKeyPath, + CaPath: config.TLSConfig.RootCAPath, + } + } else if config.TLSConfig.RequireTLS() { + // Verify that the file can be read before attempting to use it for TLS configuration, to fail fast if there are any issues with the provided path or file. + rootCA, err := os.ReadFile(config.TLSConfig.RootCAPath) + if err != nil { + return nil, fmt.Errorf("unable to read root CA certificate: %w", err) + } + if len(rootCA) == 0 { + return nil, fmt.Errorf("root CA certificate file is empty: %s", config.TLSConfig.RootCAPath) + } + + clusterConfig.SslOpts = &gocql.SslOptions{ + EnableHostVerification: true, + CaPath: config.TLSConfig.RootCAPath, + } + } + + return clusterConfig.CreateSession() +} diff --git a/pkg/server/datastore/cassandra/design/README.md b/pkg/server/datastore/cassandra/design/README.md new file mode 100644 index 0000000000..223036b823 --- /dev/null +++ b/pkg/server/datastore/cassandra/design/README.md @@ -0,0 +1,47 @@ +# Cassandra datastore implementation +This is a proof-of-concept implementation of Apache Cassandra as a backing datastore for SPIRE server. It leverages Cassandra 5.0 and is not compatible with earlier versions of Cassandra due to heavily leveraging Storage-Attached Indexes to improve query performance. + +## Background +For more about the pluggable datastore experiment and design, see the [experimental plugin design document](../../../plugin/datastore/README.md). This document describes the reference plugin implementation for Cassandra, demonstrating the viability of pluggability in the datastore layer. + +### About Cassandra +Apache Cassandra is a write-optimized, masterless NoSQL distributed database based around a table and wide column storage model. Cassandra offers tunable consistency at the query level, allowing developers to determine how much performance they are willing to sacrifice for strong consistency. Most queries in Cassandra are executed at some level of eventual consistency. + +Cassandra shines in providing global availability with acceptable performance on both the write and read path. Unlike traditional relational databases, Cassandra does not use a master or leader as the write node. Instead, any node may act as the coordinator for a query, and ensures that the data is written to the right members of the cluster, or retrieved from the right members of the cluster. To learn more about Apache Cassandra's design, read [the Cassandra docs](https://cassandra.apache.org/_/cassandra-basics.html). Consistency is tunable on a per-connection or per-query level. Data is replicated across members in the cluster according to a configurable replication strategy defined in the keyspace. + +### Why Cassandra for SPIRE? +SPIRE server deployments have long been limited in horizontal scalability by the need to have a Postgres or MySQL cluster that is reliable enough to not lose data. In those database systems, this means geo-replication can happen passively or actively, but one cluster member in one region globally must be the writer at all times. Failing over is an event that incures some amount of downtime, unless one is willing to lose data. + +Cassandra solves these problems by allowing SPIRE servers to horizontally scale a single trust domain across regions to an unparalleled level. Cassandra is not an easy database to learn, nor an easy database to operate, but it offers outstanding performance and scalability when properly understood and deployed. My hope in sharing this implementation with the SPIRE community is to demonstrate that evolution in the data storage layer in SPIRE is needed, and that it's possible. + +## Cassandra Schema +The SPIRE server schema for Apache Cassandra uses a number of tables to store data for various entities: +- `registered_entries`: holds information about entries and their attributes +- `registration_entry_events`: holds information about changes in entry state and the time it occured, used by the SPIRE server for caching. +- `attested_node_entries`: holds information about attested nodes and their attributes. +- `attested_node_entries_events`: holds information about changes in node state and the time it occured, used by the SPIRE server for caching. +- `bundles`: used to store information about bundles from federated trust domains. +- `ca_journals`: used to store information by the SPIRE server about its active metadata. +- `federated_trust_domains`: used to store information by the SPIRE server about federated trust domain relationships. +- `join_tokens`: used to store information by the SPIRE server about join tokens issued by the server for agents to join the trust domain. + +Cassandra uses a non-relational model where data is denormalized and stored according to how clients will query the data. This means that inside these tables, each of them may store information about how to find other records in other tables in ways that would surprise a newcomer to Cassandra. Also, in many cases data is repeated across rows in a partition, or otherwise represented in several ways in the same row! Again, data in Cassandra is modeled by how the client will query it, which makes repetition of a piece of data often the best way to allow clients to query that data in various ways. + +### Storage-Attached Indexes +Cassandra 5.0 introduced support for Storage-Attached Indexing, which allows performant filtering on non-partition key columns across partitions in `SELECT` statements. SPIRE Server exposes a very expressive API for `List*` RPC calls allowing users to provide arbitrary filters for many entities, including `RegistrationEntries`, `AttestedNodes` and other entities. These filters support multi-field filtering on arbitrary user-provided values. The provided filter fields are `AND`ed together. Cassandra has historically struggled with these kinds of queries, but Storage-Attached Indexing allows for much more performant and effecient cross-partition queries on an indexed column. The Cassandra datastore plugin leverages these indexes to implement the user filtering. For more information on these indexes, see the [001-spire-schema.cql](../migrations/001-spire-schema.cql) file. + +### Denormalization and indexing +WIP + +## General Limitations +- Cassandra will not return records in an order according to their creation time across partitions. Expect that the Cassandra plugin will have unpredicatable return ordering in comparison with the SQL database plugin. +- Cassandra pagination does not significantly reduce load on the database, so use pagination only where it makes sense because of truly unrealistic result sets. Do not treat pagination as an optimization - it is not when using the Cassandra datastore. +- This plugin is WIP and has not yet been reported running at scale. + +## Pagination + +## Filtering + +## Ordering + +## Test suite progress diff --git a/pkg/server/datastore/cassandra/entries.go b/pkg/server/datastore/cassandra/entries.go new file mode 100644 index 0000000000..3cce2b91d3 --- /dev/null +++ b/pkg/server/datastore/cassandra/entries.go @@ -0,0 +1,1092 @@ +package cassandra + +import ( + "context" + "encoding/base64" + "fmt" + "maps" + "slices" + "strings" + "time" + "unicode" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + "github.com/gogo/status" + "github.com/sirupsen/logrus" + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + "github.com/spiffe/spire/pkg/common/telemetry" + "github.com/tjons/cassandra-toolbox/qb" + "google.golang.org/grpc/codes" +) + +func (p *Plugin) CountRegistrationEntries(ctx context.Context, req *datastorev1.CountRegistrationEntriesRequest) (*datastorev1.CountRegistrationEntriesResponse, error) { + args := []any{} + fields := []string{} + operators := []string{} + if len(req.ByParentId) > 0 { + args = append(args, req.ByParentId) + fields = append(fields, "parent_id") + operators = append(operators, "=") + } + + if len(req.BySpiffeId) > 0 { + args = append(args, req.BySpiffeId) + fields = append(fields, "spiffe_id") + operators = append(operators, "=") + } + + if req.FilterByDownstream { + args = append(args, req.DownstreamValue) + fields = append(fields, "downstream") + operators = append(operators, "=") + } + + if req.ByFederatesWith != nil && len(req.ByFederatesWith.FederatesWith) > 0 { + args = append(args, req.ByFederatesWith.FederatesWith) + fields = append(fields, "federated_trust_domains") + operators = append(operators, "CONTAINS") + } + + if len(req.ByHint) > 0 { + args = append(args, req.ByHint) + fields = append(fields, "hint") + operators = append(operators, "=") + } + + if req.BySelectors != nil { + // TODO(tjons): implement selector-based counting + return &datastorev1.CountRegistrationEntriesResponse{ + Count: 0, + }, nil + } + + b := strings.Builder{} + b.WriteString("SELECT DISTINCT entry_id, COUNT(*) FROM registered_entries") + if len(fields) > 0 { + b.WriteString(" WHERE ") + for i, field := range fields { + if i > 0 { + b.WriteString(" AND ") + } + b.WriteString(field) + b.WriteString(" ") + b.WriteString(operators[i]) + b.WriteString(" ?") + } + } + + query := b.String() + cqlQuery := p.db.session.Query(query, args...) + cqlQuery.Consistency(p.db.cfg.ReadConsistency) + + var ( + count int32 + dontCare string + ) + if err := cqlQuery.ScanContext(ctx, &dontCare, &count); err != nil { + return nil, newWrappedCassandraError(err) + } + + return &datastorev1.CountRegistrationEntriesResponse{ + Count: count, + }, nil +} + +// TODO(tjons): should this really be there with no validation? is this effectively unused? +func (p *Plugin) CreateRegistrationEntry(ctx context.Context, req *datastorev1.CreateRegistrationEntryRequest) (*datastorev1.CreateRegistrationEntryResponse, error) { + if req.GetEntry() == nil { + return nil, newValidationError("invalid request: missing registration entry") + } + + if err := validateRegistrationEntry(req.GetEntry()); err != nil { + return nil, err + } + + if req.GetEntry().FederatesWith != nil { + bundles, err := p.ListBundles(ctx, &datastorev1.ListBundlesRequest{}) + if err != nil { + return nil, newWrappedCassandraError(err) + } + + ftds := make(map[string]bool, len(req.GetEntry().FederatesWith)) + for _, ftd := range req.GetEntry().FederatesWith { + ftds[ftd] = false + } + + for _, b := range bundles.Bundles { + if _, ok := ftds[b.TrustDomainId]; ok { + ftds[b.TrustDomainId] = true + } + } + + for ftd, found := range ftds { + if !found { + return nil, fmt.Errorf("unable to find federated bundle %q", ftd) + } + } + } + + newEntry, err := p.createRegistrationEntry(ctx, req.GetEntry()) + if err != nil { + return nil, err + } + + err = p.createRegistrationEntryEvent(ctx, &datastorev1.RegistrationEntryEvent{ + EntryId: newEntry.EntryId, + }) + + return &datastorev1.CreateRegistrationEntryResponse{ + Entry: newEntry, + }, err +} + +func (p *Plugin) createRegistrationEntry(ctx context.Context, entry *datastorev1.RegistrationEntry) (*datastorev1.RegistrationEntry, error) { + var entryID string + + if len(entry.EntryId) > 0 { + entryID = entry.EntryId + } else { + uuid, err := gocql.RandomUUID() + if err != nil { + return nil, newWrappedCassandraError(err) + } + entryID = uuid.String() + } + + entry.EntryId = entryID + entry.CreatedAt = time.Now().Unix() + entry.UpdatedAt = entry.CreatedAt + + b := p.db.session.Batch(gocql.LoggedBatch).Consistency(p.db.cfg.WriteConsistency) + + indexes := buildIndexesForRegistrationEntry(entry) + + createEntryQuery := ` + INSERT INTO registered_entries ( + created_at, + updated_at, + entry_id, + spiffe_id, + parent_id, + admin, + downstream, + ttl, + expiry, + revision_number, + store_svid, + hint, + jwt_svid_ttl, + dns_names, + federated_trust_domains, + selector_types, + selector_values, + index_terms, + selector_type_value_full, + federated_trust_domains_full, + unrolled_selector_type_val, + unrolled_ftd + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + + selectorTypes := make([]string, 0, len(entry.Selectors)) + selectorValues := make([]string, 0, len(entry.Selectors)) + selectorTypeValueFull := make([]string, 0, len(entry.Selectors)) + + for _, sl := range entry.Selectors { + selectorTypes = append(selectorTypes, sl.Type) + selectorValues = append(selectorValues, sl.Value) + selectorTypeValueFull = append(selectorTypeValueFull, sl.Type+"|"+sl.Value) + } + + commonVals := []any{ + entry.CreatedAt, + entry.UpdatedAt, + entry.EntryId, + entry.SpiffeId, + entry.ParentId, + entry.Admin, + entry.Downstream, + entry.X509SvidTtl, + entry.EntryExpiry, + entry.RevisionNumber, + entry.StoreSvid, + entry.Hint, + entry.JwtSvidTtl, + entry.DnsNames, + entry.FederatesWith, + selectorTypes, + selectorValues, + indexes, + selectorTypeValueFull, + entry.FederatesWith, + } + + b.Entries = []gocql.BatchEntry{ + { + Stmt: createEntryQuery, + Args: append(commonVals, "", ""), + }, + } + + for _, sl := range entry.Selectors { + selVal := sl.Type + "|" + sl.Value + + b.Entries = append(b.Entries, gocql.BatchEntry{ + Stmt: createEntryQuery, + Args: append(commonVals, selVal, ""), + }) + } + + for _, ftd := range entry.FederatesWith { + b.Entries = append(b.Entries, gocql.BatchEntry{ + Stmt: createEntryQuery, + Args: append(commonVals, "", ftd), + }) + } + + if err := b.ExecContext(ctx); err != nil { + return nil, newWrappedCassandraError(err) + } + + return entry, nil +} + +// Copied verbatim from pkg/server/datastore/sqlstore/sqlstore.go:39 +var validEntryIDChars = &unicode.RangeTable{ + R16: []unicode.Range16{ + {0x002d, 0x002e, 1}, // - | . + {0x0030, 0x0039, 1}, // [0-9] + {0x0041, 0x005a, 1}, // [A-Z] + {0x005f, 0x005f, 1}, // _ + {0x0061, 0x007a, 1}, // [a-z] + }, + LatinOffset: 5, +} + +// copied verbatim from pkg/server/datastore/sqlstore/sqlstore.go:4451 +// TODO(tjons): refactor this out into some helpers +func validateRegistrationEntry(entry *datastorev1.RegistrationEntry) error { + if entry == nil { + return newValidationError("invalid request: missing registered entry") + } + + if len(entry.Selectors) == 0 { + return newValidationError("invalid registration entry: missing selector list") + } + + // In case of StoreSvid is set, all entries 'must' be the same type, + // it is done to avoid users to mix selectors from different platforms in + // entries with storable SVIDs + if entry.StoreSvid { + // Selectors must never be empty + tpe := entry.Selectors[0].Type + for _, t := range entry.Selectors { + if tpe != t.Type { + return newValidationError("invalid registration entry: selector types must be the same when store SVID is enabled") + } + } + } + + if len(entry.EntryId) > 255 { + return newValidationError("invalid registration entry: entry ID too long") + } + + for _, e := range entry.EntryId { + if !unicode.In(e, validEntryIDChars) { + return newValidationError("invalid registration entry: entry ID contains invalid characters") + } + } + + if len(entry.SpiffeId) == 0 { + return newValidationError("invalid registration entry: missing SPIFFE ID") + } + + if entry.X509SvidTtl < 0 { + return newValidationError("invalid registration entry: X509SvidTtl is not set") + } + + if entry.JwtSvidTtl < 0 { + return newValidationError("invalid registration entry: JwtSvidTtl is not set") + } + + return nil +} + +func (p *Plugin) CreateOrReturnRegistrationEntry(ctx context.Context, req *datastorev1.CreateOrReturnRegistrationEntryRequest) (*datastorev1.CreateOrReturnRegistrationEntryResponse, error) { + if err := validateRegistrationEntry(req.Entry); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := p.ListRegistrationEntries(ctx, &datastorev1.ListRegistrationEntriesRequest{ + ByParentId: req.Entry.ParentId, + BySpiffeId: req.Entry.SpiffeId, + BySelectors: &datastorev1.BySelectors{ + MatchBehavior: datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_EXACT, + Selectors: req.Entry.Selectors, + }, + }) + if err != nil { + return &datastorev1.CreateOrReturnRegistrationEntryResponse{ + Created: false, + }, newWrappedCassandraError(err) + } + + if len(resp.Entries) > 0 { + return &datastorev1.CreateOrReturnRegistrationEntryResponse{ + Entry: resp.Entries[0], + Created: false, + }, nil + } + + createEntryResp, err := p.CreateRegistrationEntry(ctx, &datastorev1.CreateRegistrationEntryRequest{ + Entry: req.Entry, + }) + if err != nil { + return nil, err + } + newEntry := createEntryResp.GetEntry() + + if err := p.createRegistrationEntryEvent(ctx, &datastorev1.RegistrationEntryEvent{ + EntryId: newEntry.EntryId, + }); err != nil { + return nil, err + } + + return &datastorev1.CreateOrReturnRegistrationEntryResponse{ + Entry: newEntry, + Created: true, + }, nil +} + +func (p *Plugin) DeleteRegistrationEntry(ctx context.Context, req *datastorev1.DeleteRegistrationEntryRequest) (*datastorev1.DeleteRegistrationEntryResponse, error) { + entries, err := p.fetchRegistrationEntries(ctx, []string{req.EntryId}) + if err != nil { + return nil, newWrappedCassandraError(err) + } + + if entries[req.EntryId] == nil { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + + if err := p.deleteRegistrationEntry(ctx, entries[req.EntryId]); err != nil { + return nil, newWrappedCassandraError(err) + } + + if err := p.createRegistrationEntryEvent(ctx, &datastorev1.RegistrationEntryEvent{ + EntryId: req.EntryId, + }); err != nil { + return nil, err + } + + return &datastorev1.DeleteRegistrationEntryResponse{ + Entry: entries[req.EntryId], + }, nil +} + +func (p *Plugin) deleteRegistrationEntry(ctx context.Context, re *datastorev1.RegistrationEntry) error { + b := p.db.session.Batch(gocql.LoggedBatch).Consistency(p.db.cfg.WriteConsistency) + + const deleteEntryRowsQuery = `DELETE FROM registered_entries WHERE entry_id = ?` + const deleteFederatedBundlesQuery = `DELETE FROM bundles WHERE trust_domain = ? AND federated_entry_id = ?` + b.Entries = []gocql.BatchEntry{ + { + Stmt: deleteEntryRowsQuery, + Args: []any{re.EntryId}, + Idempotent: true, + }, + } + + for _, ftd := range re.FederatesWith { + b.Entries = append(b.Entries, gocql.BatchEntry{ + Stmt: deleteFederatedBundlesQuery, + Args: []any{ftd, re.EntryId}, + Idempotent: true, + }) + } + + if err := b.ExecContext(ctx); err != nil { + return newWrappedCassandraError(err) + } + + return nil +} + +func (p *Plugin) FetchRegistrationEntry(ctx context.Context, req *datastorev1.FetchRegistrationEntryRequest) (*datastorev1.FetchRegistrationEntryResponse, error) { + entries, err := p.fetchRegistrationEntries(ctx, []string{req.EntryId}) + if err != nil { + return nil, err + } + + return &datastorev1.FetchRegistrationEntryResponse{ + Entry: entries[req.EntryId], + }, nil +} + +func (p *Plugin) fetchRegistrationEntries(ctx context.Context, entryIDs []string) (map[string]*datastorev1.RegistrationEntry, error) { + fetchRegistrationEntriesQuery := ` + SELECT + created_at, + updated_at, + entry_id, + spiffe_id, + parent_id, + ttl, + admin, + downstream, + expiry, + revision_number, + store_svid, + hint, + jwt_svid_ttl, + dns_names, + federated_trust_domains, + selector_type_value_full + FROM registered_entries + ` + + args := []any{} + cleanedEntryIDs := make([]string, 0, len(entryIDs)) + for _, id := range entryIDs { + if len(id) > 0 { + cleanedEntryIDs = append(cleanedEntryIDs, id) + } + } + if len(cleanedEntryIDs) > 0 { + args = append(args, cleanedEntryIDs) + fetchRegistrationEntriesQuery += " WHERE entry_id IN ? ALLOW FILTERING" + } + // TODO(tjons): I don't think we need to ALLOW FILTERING here because we have an SAI on entry_id + // but cassandra is rejecting the query during the statement preparation phase unless we include it. + // Investigate further. + + query := p.db.session.Query(fetchRegistrationEntriesQuery, args...).Consistency(p.db.cfg.ReadConsistency) + + iter := query.IterContext(ctx) + entryMap := make(map[string]*datastorev1.RegistrationEntry, iter.NumRows()) + scanner := iter.Scanner() + + // Since entries can have multiple selectors, we need to aggregate them + + for scanner.Next() { + var ( + result = new(datastorev1.RegistrationEntry) + selectors = []string{} + rnum int64 + ) + + err := scanner.Scan( + &result.CreatedAt, + &result.UpdatedAt, + &result.EntryId, + &result.SpiffeId, + &result.ParentId, + &result.X509SvidTtl, + &result.Admin, + &result.Downstream, + &result.EntryExpiry, + &rnum, + &result.StoreSvid, + &result.Hint, + &result.JwtSvidTtl, + &result.DnsNames, + &result.FederatesWith, + &selectors, + ) + if err != nil { + return nil, newWrappedCassandraError(err) + } + + result.RevisionNumber = rnum + result.Selectors = selectorStringsToSelectorObjs(selectors) + entryMap[result.EntryId] = result + } + + if err := scanner.Err(); err != nil { + return nil, newWrappedCassandraError(err) + } + + return entryMap, nil +} + +func (p *Plugin) FetchRegistrationEntries(ctx context.Context, req *datastorev1.FetchRegistrationEntriesRequest) (*datastorev1.FetchRegistrationEntriesResponse, error) { + resp, err := p.fetchRegistrationEntries(ctx, req.EntryIds) + if err != nil { + return nil, err + } + + return &datastorev1.FetchRegistrationEntriesResponse{ + Entries: slices.Collect(maps.Values(resp)), + }, nil +} + +type queryTerm struct { + field string + operator string + values []any + deepValues [][]any + requireDistinct bool + includeExtraColumn bool +} + +func (p *Plugin) ListRegistrationEntries(ctx context.Context, req *datastorev1.ListRegistrationEntriesRequest) (*datastorev1.ListRegistrationEntriesResponse, error) { + if req.Pagination != nil { + if req.Pagination.PageSize == 0 { + return nil, status.Error(codes.InvalidArgument, "cannot paginate with pagesize = 0") + } + + if len(req.Pagination.PageToken) > 0 { + pToken, err := base64.URLEncoding.Strict().DecodeString(req.Pagination.PageToken) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "could not parse token '%s'", req.Pagination.PageToken) + } + req.Pagination.PageToken = string(pToken) // TODO(tjons): clean this up and avoid the mutation + } + } + if req.BySelectors != nil && len(req.BySelectors.Selectors) == 0 { + return nil, status.Error(codes.InvalidArgument, "cannot list by empty selector set") + } + + collapseToPartitionRow := true + onlyFiltersStaticCols := true + terms := []queryTerm{} + if len(req.ByParentId) > 0 { + terms = append(terms, queryTerm{ + field: "parent_id", + operator: "=", + values: []any{req.ByParentId}, + }) + } + + if len(req.BySpiffeId) > 0 { + terms = append(terms, queryTerm{ + field: "spiffe_id", + operator: "=", + values: []any{req.BySpiffeId}, + }) + } + + if req.FilterByDownstream { + terms = append(terms, queryTerm{ + field: "downstream", + operator: "=", + values: []any{req.DownstreamValue}, + }) + } + + if len(req.ByHint) > 0 { + terms = append(terms, queryTerm{ + field: "hint", + operator: "=", + values: []any{req.ByHint}, + }) + } + + if req.ByFederatesWith != nil || req.BySelectors != nil { + indexes := generateSearchIndexesForRequest(req) + terms = append(terms, indexes...) + // TODO(tjons): this has to be temp + + for _, idx := range indexes { + if idx.operator == "IN" { + collapseToPartitionRow = false + } + } + } + + addDistinctionColumn := false + b := strings.Builder{} + b.WriteString(` + SELECT + created_at, + updated_at, + entry_id, + spiffe_id, + parent_id, + ttl, + admin, + downstream, + expiry, + revision_number, + store_svid, + hint, + jwt_svid_ttl, + dns_names, + federated_trust_domains, + selector_types, + selector_values + FROM registered_entries + `) + if collapseToPartitionRow { + // b.WriteString(" unrolled_selector_type_val = '' AND unrolled_ftd = '' ") // no filtering at all, get all entries but limit this to the empty row for paging + // if len(terms) > 0 { + // b.WriteString(" AND ") + // } + // needsDistinct = true + } + + args := make([]any, 0, len(terms)) + if len(terms) > 0 { + b.WriteString("WHERE ") + + for i, term := range terms { + if i > 0 { + b.WriteString(" AND ") + } + b.WriteString(term.field) + b.WriteString(" ") + b.WriteString(term.operator) + + if term.operator == "IN" { + if !addDistinctionColumn { + addDistinctionColumn = term.includeExtraColumn + onlyFiltersStaticCols = false + } + + if term.requireDistinct { + onlyFiltersStaticCols = true + } + // TODO(tjons): the logic in here is actually kinda dangerous + if len(term.deepValues) > 0 { + b.WriteString(" (") + b.WriteString(strings.TrimRight(strings.Repeat(" ?,", len(term.deepValues)), ",")) + b.WriteString(")") + for _, dv := range term.deepValues { + args = append(args, dv) + } + continue + } + + b.WriteString(" (") + b.WriteString(strings.TrimRight(strings.Repeat(" ?,", len(term.values)), ",")) + b.WriteString(")") + } else { + b.WriteString(" ?") + } + + args = append(args, term.values...) + } + } + + b.WriteString(" ALLOW FILTERING") + + query := b.String() + if !addDistinctionColumn { + query = strings.Replace(query, "updated_at,", "", 1) + } + + if onlyFiltersStaticCols { + query = strings.Replace(query, "SELECT", "SELECT DISTINCT", 1) + } + + cqlQuery := p.db.session.Query(query, args...).Consistency(p.db.cfg.ReadConsistency) + + if req.Pagination != nil { + cqlQuery.PageSize(int(req.Pagination.PageSize)) + + if len(req.Pagination.PageToken) > 0 { + cqlQuery = cqlQuery.PageState([]byte(req.Pagination.PageToken)) + } else { + cqlQuery = cqlQuery.PageState(nil) + } + } else { + cqlQuery.PageSize(100_000_000) // effectively no limit + } + + iter := cqlQuery.IterContext(ctx) + entryMap := make(map[string]*datastorev1.RegistrationEntry, iter.NumRows()) + scanner := iter.Scanner() + + for scanner.Next() { + var ( + result = new(datastorev1.RegistrationEntry) + selectorTypes, selectorValues []string + err error + ) + + if !addDistinctionColumn { + err = scanner.Scan( + &result.CreatedAt, + &result.EntryId, + &result.SpiffeId, + &result.ParentId, + &result.X509SvidTtl, + &result.Admin, + &result.Downstream, + &result.EntryExpiry, + &result.RevisionNumber, + &result.StoreSvid, + &result.Hint, + &result.JwtSvidTtl, + &result.DnsNames, + &result.FederatesWith, + &selectorTypes, + &selectorValues, + ) + } else { + err = scanner.Scan( + &result.CreatedAt, + &result.UpdatedAt, + &result.EntryId, + &result.SpiffeId, + &result.ParentId, + &result.X509SvidTtl, + &result.Admin, + &result.Downstream, + &result.EntryExpiry, + &result.RevisionNumber, + &result.StoreSvid, + &result.Hint, + &result.JwtSvidTtl, + &result.DnsNames, + &result.FederatesWith, + &selectorTypes, + &selectorValues, + ) + } + if err != nil { + return nil, newWrappedCassandraError(err) + } + + for i := range selectorTypes { + selector := &datastorev1.Selector{ + Type: selectorTypes[i], + Value: selectorValues[i], + } + result.Selectors = append(result.Selectors, selector) + } + + entryMap[result.EntryId] = result + } + + if err := scanner.Err(); err != nil { + return nil, newWrappedCassandraError(err) + } + pageState := iter.PageState() + + r := &datastorev1.ListRegistrationEntriesResponse{ + Entries: slices.Collect(maps.Values(entryMap)), + } + + if req.Pagination != nil { + r.Pagination = &datastorev1.Pagination{ + PageSize: req.Pagination.PageSize, + } + + // go ahead and "peek" if there is a next page... + peeker := p.db.session.Query(query, args...).Consistency(p.db.cfg.ReadConsistency) + + peeker.PageState(pageState) + peeker.PageSize(1) // I hate all this and i think it would be better if we just dropped the silly next pagination requirement for cassandra + peekIter := peeker.IterContext(ctx) // at a minimum, we should feature flag this + if peekIter.NumRows() > 0 { + r.Pagination.PageToken = base64.URLEncoding.Strict().EncodeToString(pageState) + } + if err := peekIter.Close(); err != nil { + return nil, newWrappedCassandraError(err) + } + } + + return r, nil +} + +func (p *Plugin) PruneRegistrationEntries(ctx context.Context, req *datastorev1.PruneRegistrationEntriesRequest) (*datastorev1.PruneRegistrationEntriesResponse, error) { + selectPruneQuery := ` + SELECT DISTINCT entry_id, spiffe_id, parent_id, federated_trust_domains FROM registered_entries WHERE expiry < ? AND expiry > 0 ALLOW FILTERING + ` + query := p.db.session.Query(selectPruneQuery, req.ExpiresBefore).Consistency(p.db.cfg.ReadConsistency) + iter := query.IterContext(ctx) + + type entryToPrune struct { + entryID string + spiffeID string + parentID string + federatedTrustDomains []string + } + + entries := make([]entryToPrune, 0, iter.NumRows()) + scanner := iter.Scanner() + + for scanner.Next() { + var entry entryToPrune + err := scanner.Scan(&entry.entryID, &entry.spiffeID, &entry.parentID, &entry.federatedTrustDomains) + if err != nil { + return nil, newWrappedCassandraError(err) + } + entries = append(entries, entry) + } + if err := iter.Close(); err != nil { + return nil, newWrappedCassandraError(err) + } + + deletePruneQueryBuilder := strings.Builder{} + deletePruneQueryBuilder.WriteString(`DELETE FROM registered_entries WHERE entry_id IN (`) + + delIds := make([]any, len(entries)) + b := p.db.session.Batch(gocql.LoggedBatch).Consistency(p.db.cfg.WriteConsistency) + + for i := range entries { + if i > 0 { + deletePruneQueryBuilder.WriteString(",") + } + deletePruneQueryBuilder.WriteString("?") + + if len(entries[i].federatedTrustDomains) > 0 { + deleteBundlesArgs := make([]any, 0) + deleteBundlesQueryBuilder := strings.Builder{} + deleteBundlesQueryBuilder.WriteString(`DELETE FROM bundles WHERE trust_domain IN (`) + for _, td := range entries[i].federatedTrustDomains { + deleteBundlesQueryBuilder.WriteString("?,") + deleteBundlesArgs = append(deleteBundlesArgs, td) + } + deleteBundlesQueryBuilder.WriteString(")") + deleteBundlesQueryBuilder.WriteString(" AND federated_entry_id = ?") + deleteBundlesArgs = append(deleteBundlesArgs, entries[i].entryID) + + b.Query(deleteBundlesQueryBuilder.String(), deleteBundlesArgs...) + } + + delIds[i] = entries[i].entryID + } + deletePruneQueryBuilder.WriteString(")") + + b.Query(deletePruneQueryBuilder.String(), delIds...) + + if err := b.ExecContext(ctx); err != nil { + return nil, newWrappedCassandraError(err) + } + + for _, entry := range entries { + if err := p.createRegistrationEntryEvent(ctx, &datastorev1.RegistrationEntryEvent{ + EntryId: entry.entryID, + }); err != nil { + p.log.WithError(err).WithField(telemetry.RegistrationID, entry.entryID).Error("Failed to create registration entry event for pruned entry") + } + + p.log.WithFields(logrus.Fields{ + telemetry.SPIFFEID: entry.spiffeID, + telemetry.ParentID: entry.parentID, + telemetry.RegistrationID: entry.entryID, + }).Info("Pruned an expired registration") + } + + return &datastorev1.PruneRegistrationEntriesResponse{}, nil +} + +func (p *Plugin) UpdateRegistrationEntry(ctx context.Context, req *datastorev1.UpdateRegistrationEntryRequest) (*datastorev1.UpdateRegistrationEntryResponse, error) { + if req.GetEntry() == nil { + return nil, status.Error(codes.InvalidArgument, "invalid request: missing registration entry") + } + + re := req.GetEntry() + mask := req.GetMask() + + if err := validateRegistrationEntryForUpdate(re, mask); err != nil { + return nil, err + } + + entryResp, err := p.FetchRegistrationEntry(ctx, &datastorev1.FetchRegistrationEntryRequest{ + EntryId: re.EntryId, + }) + if err != nil { + return nil, newWrappedCassandraError(err) + } + if entryResp.GetEntry() == nil { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + entry := entryResp.GetEntry() + + b := p.db.session.Batch(gocql.LoggedBatch).Consistency(p.db.cfg.WriteConsistency) + updateQuery := qb.NewUpdate(). + Table("registered_entries"). + Set("updated_at", qb.CqlFunction("toTimestamp(now())")). + Where("entry_id", qb.Equals(re.EntryId)) + + if mask == nil || mask.StoreSvid { + entry.StoreSvid = re.StoreSvid + updateQuery = updateQuery.Set("store_svid", re.StoreSvid) + } + + selectors := make(map[string]*datastorev1.Selector, len(re.Selectors)) + selectorsToDelete := make(map[string]*datastorev1.Selector, len(entry.Selectors)) + if mask == nil || mask.Selectors { + for _, s := range entry.Selectors { + key := selectorToString(s) + selectorsToDelete[key] = s + } + + for _, s := range re.Selectors { + key := selectorToString(s) + selectors[key] = s + delete(selectorsToDelete, key) + } + + entry.Selectors = slices.Collect(maps.Values(selectors)) + stvFull := slices.Collect(maps.Keys(selectors)) + slices.Sort(stvFull) + updateQuery = updateQuery.Set("selector_type_value_full", stvFull) + + for stvToDelete := range selectorsToDelete { + deleteQuery := qb.NewDelete(). + From("registered_entries"). + Where("entry_id", qb.Equals(re.EntryId)). + Where("unrolled_selector_type_val", qb.Equals(stvToDelete)) + + q, _ := deleteQuery.Build() + b.Query(q, deleteQuery.QueryValues()...) + } + + } + + if entry.StoreSvid { + typ := "" + + // Ensure that all selectors are of the same type + for _, s := range selectors { + switch { + case typ == "": + typ = s.Type + case typ != s.Type: + return nil, status.Error(codes.InvalidArgument, newValidationError("invalid registration entry: selector types must be the same when store SVID is enabled").Error()) + } + } + } + + if mask == nil || mask.DnsNames { + entry.DnsNames = re.DnsNames + updateQuery = updateQuery.Set("dns_names", re.DnsNames) + } + + if mask == nil || mask.SpiffeId { + entry.SpiffeId = re.SpiffeId + updateQuery = updateQuery.Set("spiffe_id", re.SpiffeId) + } + + if mask == nil || mask.ParentId { + entry.ParentId = re.ParentId + updateQuery = updateQuery.Set("parent_id", re.ParentId) + } + + if mask == nil || mask.X509SvidTtl { + entry.X509SvidTtl = re.X509SvidTtl + updateQuery = updateQuery.Set("ttl", re.X509SvidTtl) + } + + if mask == nil || mask.JwtSvidTtl { + entry.JwtSvidTtl = re.JwtSvidTtl + updateQuery = updateQuery.Set("jwt_svid_ttl", re.JwtSvidTtl) + } + + if mask == nil || mask.Admin { + entry.Admin = re.Admin + updateQuery = updateQuery.Set("admin", re.Admin) + } + + if mask == nil || mask.Downstream { + entry.Downstream = re.Downstream + updateQuery = updateQuery.Set("downstream", re.Downstream) + } + + if mask == nil || mask.EntryExpiry { + entry.EntryExpiry = re.EntryExpiry + updateQuery = updateQuery.Set("expiry", re.EntryExpiry) + } + + if mask == nil || mask.Hint { + entry.Hint = re.Hint + updateQuery = updateQuery.Set("hint", re.Hint) + } + + if mask == nil || mask.FederatesWith { + // TODO(tjons): probably smarter to set the read path to only read from static cols so that we do not have to worry about + // potential inconsistencies + updateQuery.Set("federated_trust_domains_full", re.FederatesWith) + updateQuery.Set("federated_trust_domains", re.FederatesWith) + + trustDomainsToDelete := make(map[string]struct{}, len(entry.FederatesWith)) + for _, td := range entry.FederatesWith { + trustDomainsToDelete[td] = struct{}{} + } + for _, td := range re.FederatesWith { + delete(trustDomainsToDelete, td) + } + + for tdToDelete := range trustDomainsToDelete { + deleteFieldQuery := qb.NewDelete(). + From("registered_entries"). + Where("entry_id", qb.Equals(re.EntryId)). + Where("unrolled_selector_type_val", qb.Equals("")). + Where("unrolled_ftd", qb.Equals(tdToDelete)) + + q, _ := deleteFieldQuery.Build() + b.Query(q, deleteFieldQuery.QueryValues()...) + + deleteFtdLinkQuery := qb.NewDelete(). + From("bundles"). + Where("trust_domain", qb.Equals(tdToDelete)). + Where("federated_entry_id", qb.Equals(re.EntryId)) + + q, _ = deleteFtdLinkQuery.Build() + b.Query(q, deleteFtdLinkQuery.QueryValues()...) + } + + // make sure to do the write to the read object here, instead of anywhere earlier. + entry.FederatesWith = re.FederatesWith + } + + entry.RevisionNumber++ + + updateQuery.Set("revision_number", entry.RevisionNumber) + + // Rebuild indexes + updateQuery.Set("index_terms", buildIndexesForRegistrationEntry(entry)) + + // It's easiest for us to write to the NULL rows for these unrolled columns + // here and then handle updating them in a separate batch query. + updateQuery.Where("unrolled_ftd", qb.Equals("")) + updateQuery.Where("unrolled_selector_type_val", qb.Equals("")) + + q, _ := updateQuery.Build() + b.Query(q, updateQuery.QueryValues()...) + + if err := b.ExecContext(ctx); err != nil { + return nil, newWrappedCassandraError(err) + } + + err = p.createRegistrationEntryEvent(ctx, &datastorev1.RegistrationEntryEvent{ + EntryId: re.EntryId, + }) + if err != nil { + return nil, newWrappedCassandraError(err) + } + + return &datastorev1.UpdateRegistrationEntryResponse{ + Entry: entry, + }, nil +} + +func validateRegistrationEntryForUpdate(entry *datastorev1.RegistrationEntry, mask *datastorev1.RegistrationEntryMask) error { + if entry == nil { + return newValidationError("invalid request: missing registered entry") + } + + if (mask == nil || mask.Selectors) && len(entry.Selectors) == 0 { + return newValidationError("invalid registration entry: missing selector list") + } + + if (mask == nil || mask.SpiffeId) && + entry.SpiffeId == "" { + return newValidationError("invalid registration entry: missing SPIFFE ID") + } + + if (mask == nil || mask.X509SvidTtl) && + (entry.X509SvidTtl < 0) { + return newValidationError("invalid registration entry: X509SvidTtl is not set") + } + + if (mask == nil || mask.JwtSvidTtl) && + (entry.JwtSvidTtl < 0) { + return newValidationError("invalid registration entry: JwtSvidTtl is not set") + } + + return nil +} diff --git a/pkg/server/datastore/cassandra/entries_events.go b/pkg/server/datastore/cassandra/entries_events.go new file mode 100644 index 0000000000..13c30dc93b --- /dev/null +++ b/pkg/server/datastore/cassandra/entries_events.go @@ -0,0 +1,162 @@ +package cassandra + +import ( + "context" + "slices" + "strings" + "time" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" +) + +func (p *Plugin) ListRegistrationEntryEvents(ctx context.Context, req *datastorev1.ListRegistrationEntryEventsRequest) (*datastorev1.ListRegistrationEntryEventsResponse, error) { + b := strings.Builder{} + b.WriteString("SELECT id, entry_id FROM registration_entry_events ") + var args []any + + switch { + case req.GetLessThanEventId() > 0 && req.GetGreaterThanEventId() > 0: + return nil, newCassandraError("can't set both greater and less than event id") + case req.GetLessThanEventId() > 0: + b.WriteString("WHERE id < ? ") + args = append(args, req.GetLessThanEventId()) + case req.GetGreaterThanEventId() > 0: + b.WriteString("WHERE id > ? ") + args = append(args, req.GetGreaterThanEventId()) + } + b.WriteString(" ALLOW FILTERING") + + iter := p.db.session.Query(b.String(), args...).Consistency(p.db.cfg.ReadConsistency).IterContext(ctx) + scanner := iter.Scanner() + events := make([]*datastorev1.RegistrationEntryEvent, 0) + + for scanner.Next() { + event := new(datastorev1.RegistrationEntryEvent) + if err := scanner.Scan( + &event.EventId, + &event.EntryId, + ); err != nil { + return nil, err + } + events = append(events, event) + } + if err := scanner.Err(); err != nil { + return nil, err + } + slices.SortStableFunc(events, func(a, b *datastorev1.RegistrationEntryEvent) int { + if a.EventId < b.EventId { + return -1 + } else if a.EventId > b.EventId { + return 1 + } + return 0 + }) + + resp := &datastorev1.ListRegistrationEntryEventsResponse{ + Events: events, + } + + return resp, nil + +} + +func (p *Plugin) createRegistrationEntryEvent(ctx context.Context, event *datastorev1.RegistrationEntryEvent) error { + if event.EventId == 0 { + nextID, err := p.getNextRegistrationEntryEventID(ctx) + if err != nil { + return err + } + event.EventId = nextID + } + + q := `INSERT INTO registration_entry_events (id, entry_id, created_at, updated_at) VALUES (?, ?, ?, ?)` + if err := p.db.session.Query(q, + event.EventId, + event.EntryId, + time.Now().UTC(), + time.Now().UTC(), + ).Consistency(p.db.cfg.WriteConsistency).ExecContext(ctx); err != nil { + return err + } + + return nil +} + +func (p *Plugin) getNextRegistrationEntryEventID(ctx context.Context) (uint64, error) { + q := `SELECT max(id) FROM registration_entry_events ALLOW FILTERING` + + var maxID *uint + if err := p.db.session.Query(q).Consistency(p.db.cfg.ReadConsistency).ScanContext(ctx, &maxID); err != nil { + return 0, err + } + if maxID == nil { + return 1, nil + } + return uint64(*maxID) + 1, nil +} + +func (p *Plugin) PruneRegistrationEntryEvents(ctx context.Context, req *datastorev1.PruneRegistrationEntryEventsRequest) (*datastorev1.PruneRegistrationEntryEventsResponse, error) { + cutoff := time.Now().UTC().Add(-time.Duration(req.ExpiresBefore * int64(time.Second))) + + idsQ := `SELECT id, entry_id FROM registration_entry_events WHERE created_at < ? ALLOW FILTERING` + scanner := p.db.session.Query(idsQ, cutoff).Consistency(p.db.cfg.ReadConsistency).IterContext(ctx).Scanner() + + var events []datastorev1.RegistrationEntryEvent + for scanner.Next() { + var event datastorev1.RegistrationEntryEvent + if err := scanner.Scan(&event.EventId, &event.EntryId); err != nil { + return nil, err + } + events = append(events, event) + } + if err := scanner.Err(); err != nil { + return nil, err + } + + b := p.db.session.Batch(gocql.LoggedBatch).Consistency(p.db.cfg.WriteConsistency) + deleteQ := `DELETE FROM registration_entry_events WHERE entry_id = ? AND id = ?` + for _, event := range events { + b.Entries = append(b.Entries, gocql.BatchEntry{ + Stmt: deleteQ, + Args: []any{event.EntryId, event.EventId}, + Idempotent: true, + }) + } + if err := b.ExecContext(ctx); err != nil { + return nil, err + } + + return &datastorev1.PruneRegistrationEntryEventsResponse{}, nil +} + +func (p *Plugin) FetchRegistrationEntryEvent(ctx context.Context, req *datastorev1.FetchRegistrationEntryEventRequest) (*datastorev1.FetchRegistrationEntryEventResponse, error) { + q := `SELECT id, entry_id FROM registration_entry_events WHERE id = ?` + + var event datastorev1.RegistrationEntryEvent + if err := p.db.session.Query(q, req.EventId).Consistency(p.db.cfg.ReadConsistency).ScanContext(ctx, + &event.EventId, + &event.EntryId, + ); err != nil { + if err == gocql.ErrNotFound { + return nil, NotFoundErr + } + return nil, err + } + + return &datastorev1.FetchRegistrationEntryEventResponse{ + Event: &event, + }, nil + +} +func (p *Plugin) CreateRegistrationEntryEvent(ctx context.Context, req *datastorev1.CreateRegistrationEntryEventRequest) (*datastorev1.CreateRegistrationEntryEventResponse, error) { + return nil, p.createRegistrationEntryEvent(ctx, req.Event) +} + +func (p *Plugin) DeleteRegistrationEntryEvent(ctx context.Context, req *datastorev1.DeleteRegistrationEntryEventRequest) (*datastorev1.DeleteRegistrationEntryEventResponse, error) { + q := `DELETE FROM registration_entry_events WHERE id = ?` + if err := p.db.session.Query(q, req.EventId).Consistency(p.db.cfg.WriteConsistency).ExecContext(ctx); err != nil { + return nil, err + } + return &datastorev1.DeleteRegistrationEntryEventResponse{}, nil +} diff --git a/pkg/server/datastore/cassandra/errors.go b/pkg/server/datastore/cassandra/errors.go new file mode 100644 index 0000000000..3b8f4b2232 --- /dev/null +++ b/pkg/server/datastore/cassandra/errors.go @@ -0,0 +1,83 @@ +package cassandra + +import ( + "fmt" +) + +const ( + datastoreValidationErrorPrefix = "datastore-validation" +) + +var NotFoundErr = newCassandraError("record not found") + +type cassandraError struct { + err error + msg string +} + +func (s *cassandraError) Error() string { + if s == nil { + return "" + } + + if s.err != nil { + return s.err.Error() + } + + return s.msg +} + +func (s *cassandraError) Unwrap() error { + if s == nil { + return nil + } + + return s.err +} + +type validationError struct { + err error + msg string +} + +func (v *validationError) Error() string { + if v == nil { + return "" + } + + if v.err != nil { + return fmt.Sprintf("%s: %s", datastoreValidationErrorPrefix, v.err) + } + + return fmt.Sprintf("%s: %s", datastoreValidationErrorPrefix, v.msg) +} + +func (v *validationError) Unwrap() error { + if v == nil { + return nil + } + + return v.err +} + +func newCassandraError(fmtMsg string, args ...any) error { + return &cassandraError{ + msg: fmt.Sprintf(fmtMsg, args...), + } +} + +func newWrappedCassandraError(err error) error { + if err == nil { + return nil + } + + return &cassandraError{ + err: err, + } +} + +func newValidationError(fmtMsg string, args ...any) error { + return &validationError{ + msg: fmt.Sprintf(fmtMsg, args...), + } +} diff --git a/pkg/server/datastore/cassandra/federation_relationships.go b/pkg/server/datastore/cassandra/federation_relationships.go new file mode 100644 index 0000000000..fd0ad9b5f8 --- /dev/null +++ b/pkg/server/datastore/cassandra/federation_relationships.go @@ -0,0 +1,334 @@ +package cassandra + +import ( + "context" + "errors" + "fmt" + "strings" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + "github.com/tjons/cassandra-toolbox/qb/pages" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var AllTrueFederationRelationshipMask = &datastorev1.FederationRelationshipMask{ + BundleEndpointUrl: true, + BundleEndpointProfile: true, + TrustDomainBundle: true, +} + +func (p *Plugin) federationRelationshipExists(ctx context.Context, trustDomainID string) (bool, error) { + var id string + err := p.db.session.Query(`SELECT trust_domain FROM federated_trust_domains WHERE trust_domain = ?`, trustDomainID). + Consistency(p.db.cfg.ReadConsistency).ScanContext(ctx, &id) + if err != nil { + if errors.Is(err, gocql.ErrNotFound) { + return false, nil + } + return false, newWrappedCassandraError(fmt.Errorf("unable to check if federation relationship exists: %w", err)) + } + return true, nil +} + +func (p *Plugin) CreateFederationRelationship( + ctx context.Context, + fr *datastorev1.CreateFederationRelationshipRequest, +) (*datastorev1.CreateFederationRelationshipResponse, error) { + if err := validateFederationRelationship(fr.Relationship, AllTrueFederationRelationshipMask); err != nil { + return nil, err + } + + exists, err := p.federationRelationshipExists(ctx, fr.Relationship.TrustDomainId) + if err != nil { + return nil, newWrappedCassandraError(fmt.Errorf("unable to check if federation relationship exists: %w", err)) + } + if exists { + return nil, status.Error(codes.AlreadyExists, "federation relationship already exists") + } + + createQ := ` + INSERT INTO federated_trust_domains ( + created_at, + updated_at, + trust_domain, + bundle_endpoint_url, + bundle_endpoint_profile, + endpoint_spiffe_id + ) VALUES (toTimestamp(now()), toTimestamp(now()), ?, ?, ?, ?) + ` + + if fr.Relationship.TrustDomainBundle != nil { + _, err := p.SetBundle(ctx, &datastorev1.SetBundleRequest{ + Bundle: fr.Relationship.TrustDomainBundle, + }) + if err != nil { + return nil, fmt.Errorf("unable to set bundle: %w", err) + } + } + + var esi string + if fr.Relationship.BundleEndpointType == datastorev1.BundleEndpointType_BUNDLE_ENDPOINT_TYPE_SPIFFE { + esi = fr.Relationship.GetBundleEndpointSpiffeId() + } + + err = p.db.session.Query(createQ, + fr.Relationship.TrustDomainId, + fr.Relationship.BundleEndpointUrl, + fr.Relationship.BundleEndpointType.String(), + esi, + ).Consistency(p.db.cfg.WriteConsistency).Exec() + if err != nil { + return nil, newWrappedCassandraError(fmt.Errorf("unable to create federation relationship: %w", err)) + } + + return &datastorev1.CreateFederationRelationshipResponse{ + Relationship: fr.Relationship, + }, nil +} + +func (p *Plugin) FetchFederationRelationship(ctx context.Context, req *datastorev1.FetchFederationRelationshipRequest) (*datastorev1.FetchFederationRelationshipResponse, error) { + if req.GetTrustDomainId() == "" { + return nil, status.Error(codes.InvalidArgument, "trust domain is required") + } + + record := new(datastorev1.FederationRelationship) + fetchQ := ` + SELECT + trust_domain, + bundle_endpoint_url, + bundle_endpoint_profile, + endpoint_spiffe_id + FROM federated_trust_domains + WHERE trust_domain = ? + ` + + var bundleEndpointType string + if err := p.db.session.Query(fetchQ, req.GetTrustDomainId()).Consistency(p.db.cfg.ReadConsistency).ScanContext( + ctx, + &record.TrustDomainId, + &record.BundleEndpointUrl, + &bundleEndpointType, + &record.BundleEndpointSpiffeId, + ); err != nil { + if errors.Is(err, gocql.ErrNotFound) { + return nil, nil + } + return nil, newWrappedCassandraError(fmt.Errorf("unable to fetch federation relationship: %w", err)) + } + + trustDomainBundle, err := p.fetchBundle(ctx, req.GetTrustDomainId()) + if err != nil && !errors.Is(err, gocql.ErrNotFound) { + return nil, fmt.Errorf("unable to fetch bundle: %w", err) + } + record.TrustDomainBundle = trustDomainBundle + + if bundleEndpointType != "" { + record.BundleEndpointType = datastorev1.BundleEndpointType(datastorev1.BundleEndpointType_value[bundleEndpointType]) + } + + return &datastorev1.FetchFederationRelationshipResponse{ + Relationship: record, + }, nil +} + +func (p *Plugin) ListFederationRelationships(ctx context.Context, req *datastorev1.ListFederationRelationshipsRequest) (*datastorev1.ListFederationRelationshipsResponse, error) { + pager := pages.NewQueryPaginator(req.GetPagination() != nil, req.GetPagination().GetPageSize(), req.GetPagination().GetPageToken()) + if err := pager.Validate(); err != nil { + return nil, err + } + + listQ := `SELECT + trust_domain, + bundle_endpoint_url, + bundle_endpoint_profile, + endpoint_spiffe_id + FROM federated_trust_domains + ALLOW FILTERING + ` + query := p.db.session.Query(listQ).Consistency(p.db.cfg.ReadConsistency) + pager.BindToQuery(query) + + resp := &datastorev1.ListFederationRelationshipsResponse{ + Relationships: []*datastorev1.FederationRelationship{}, + } + iter := query.IterContext(ctx) + pager.ForIter(iter) + scanner := iter.Scanner() + for scanner.Next() { + record := new(datastorev1.FederationRelationship) + var bundleEndpointType string + if err := scanner.Scan( + &record.TrustDomainId, + &record.BundleEndpointUrl, + &bundleEndpointType, + &record.BundleEndpointSpiffeId, + ); err != nil { + return nil, newWrappedCassandraError(fmt.Errorf("unable to scan federation relationship: %w", err)) + } + + trustDomainBundle, err := p.fetchBundle(ctx, record.GetTrustDomainId()) + if err != nil && !errors.Is(err, gocql.ErrNotFound) { + return nil, fmt.Errorf("unable to fetch bundle: %w", err) + } + record.TrustDomainBundle = trustDomainBundle + + if bundleEndpointType != "" { + record.BundleEndpointType = datastorev1.BundleEndpointType(datastorev1.BundleEndpointType_value[bundleEndpointType]) + } + + resp.Relationships = append(resp.Relationships, record) + } + if err := scanner.Err(); err != nil { + return nil, newWrappedCassandraError(fmt.Errorf("unable to list federation relationships: %w", err)) + } + + resp.Pagination = responsePaginationFromPager(pager) + // nextPageState := iter.PageState() + + // if req.Pagination != nil { + // resp.Pagination = &datastore.Pagination{ + // PageSize: req.Pagination.PageSize, + // } + + // // TODO(tjons): at a minimum, toss this behind a feature flag + // peeker := p.db.session.Query(listQ).PageSize(1).PageState(nextPageState).IterContext(ctx) + // if peeker.NumRows() > 0 { + // resp.Pagination.Token = base64.URLEncoding.Strict().EncodeToString(nextPageState) + // } + // if err := peeker.Close(); err != nil { + // return nil, newWrappedCassandraError(fmt.Errorf("unable to determine pagination token: %w", err)) + // } + // } + + return resp, nil +} + +func (p *Plugin) DeleteFederationRelationship(ctx context.Context, req *datastorev1.DeleteFederationRelationshipRequest) (*datastorev1.DeleteFederationRelationshipResponse, error) { + if req.GetTrustDomainId() == "" { + return nil, status.Error(codes.InvalidArgument, "trust domain is required") + } + + fr, err := p.FetchFederationRelationship(ctx, &datastorev1.FetchFederationRelationshipRequest{ + TrustDomainId: req.GetTrustDomainId(), + }) + if err != nil { + return nil, err + } + if fr == nil { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + + deleteQ := `DELETE FROM federated_trust_domains WHERE trust_domain = ?` + err = p.db.session.Query(deleteQ, req.GetTrustDomainId()).Consistency(p.db.cfg.WriteConsistency).Exec() + if err != nil { + if errors.Is(err, gocql.ErrNotFound) { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + return nil, newWrappedCassandraError(fmt.Errorf("unable to delete federation relationship: %w", err)) + } + + return &datastorev1.DeleteFederationRelationshipResponse{}, nil +} + +func (p *Plugin) UpdateFederationRelationship( + ctx context.Context, + req *datastorev1.UpdateFederationRelationshipRequest, +) (*datastorev1.UpdateFederationRelationshipResponse, error) { + if req == nil || req.GetRelationship() == nil { + return nil, status.Error(codes.InvalidArgument, "federation relationship is required") + } + + if err := validateFederationRelationship(req.GetRelationship(), req.GetMask()); err != nil { + return nil, err + } + + exists, err := p.federationRelationshipExists(ctx, req.GetRelationship().TrustDomainId) + if err != nil { + return nil, newWrappedCassandraError(fmt.Errorf("unable to check if federation relationship exists: %w", err)) + } + if !exists { + return nil, status.Error(codes.NotFound, fmt.Errorf("unable to fetch federation relationship: %w", NotFoundErr).Error()) + } + + args := []any{} + fields := []string{} + + if req.GetMask().GetBundleEndpointUrl() { + fields = append(fields, "bundle_endpoint_url = ?") + args = append(args, req.GetRelationship().GetBundleEndpointUrl()) + } + + if req.GetMask().GetBundleEndpointProfile() { + fields = append(fields, "bundle_endpoint_profile = ?") + args = append(args, req.GetRelationship().GetBundleEndpointType().String()) + + if req.GetRelationship().GetBundleEndpointType() == datastorev1.BundleEndpointType_BUNDLE_ENDPOINT_TYPE_SPIFFE { + fields = append(fields, "endpoint_spiffe_id = ?") + args = append(args, req.GetRelationship().GetBundleEndpointSpiffeId()) + } + } + + if req.GetMask().GetTrustDomainBundle() && req.GetRelationship().GetTrustDomainBundle() != nil { + _, err := p.SetBundle(ctx, &datastorev1.SetBundleRequest{ + Bundle: req.GetRelationship().GetTrustDomainBundle(), // TODO(tjons): handle this in a batch + }) + + if err != nil { + return nil, fmt.Errorf("unable to set bundle: %w", err) + } + } + + updateQ := strings.Builder{} + updateQ.WriteString("UPDATE federated_trust_domains SET updated_at = toTimestamp(now())") + for _, field := range fields { + updateQ.WriteString(", ") + updateQ.WriteString(field) + } + updateQ.WriteString(" WHERE trust_domain = ?") + args = append(args, req.GetRelationship().GetTrustDomainId()) + + err = p.db.session.Query(updateQ.String(), args...).Consistency(p.db.cfg.WriteConsistency).Exec() + if err != nil { + return nil, newWrappedCassandraError(fmt.Errorf("unable to update federation relationship: %w", err)) + } + + fetchResp, err := p.FetchFederationRelationship(ctx, &datastorev1.FetchFederationRelationshipRequest{ + TrustDomainId: req.GetRelationship().GetTrustDomainId(), + }) + if err != nil { + return nil, err + } + return &datastorev1.UpdateFederationRelationshipResponse{ + Relationship: fetchResp.GetRelationship(), + }, nil +} + +func validateFederationRelationship(fr *datastorev1.FederationRelationship, mask *datastorev1.FederationRelationshipMask) error { + if fr == nil { + return status.Error(codes.InvalidArgument, "federation relationship is nil") + } + + if len(fr.TrustDomainId) == 0 { + return status.Error(codes.InvalidArgument, "trust domain is required") + } + + if mask.BundleEndpointUrl && len(fr.BundleEndpointUrl) == 0 { + return status.Error(codes.InvalidArgument, "bundle endpoint URL is required") + } + + if mask.BundleEndpointProfile { + switch fr.BundleEndpointType { + case datastorev1.BundleEndpointType_BUNDLE_ENDPOINT_TYPE_WEB: + case datastorev1.BundleEndpointType_BUNDLE_ENDPOINT_TYPE_SPIFFE: + if len(fr.BundleEndpointSpiffeId) == 0 { + return status.Error(codes.InvalidArgument, "bundle endpoint SPIFFE ID is required") + } + default: + return status.Errorf(codes.Unknown, "unknown bundle endpoint profile type: %q", fr.BundleEndpointType) + } + } + + return nil +} diff --git a/pkg/server/datastore/cassandra/gocql_logger.go b/pkg/server/datastore/cassandra/gocql_logger.go new file mode 100644 index 0000000000..323b866a88 --- /dev/null +++ b/pkg/server/datastore/cassandra/gocql_logger.go @@ -0,0 +1,92 @@ +package cassandra + +import ( + gocql "github.com/apache/cassandra-gocql-driver/v2" + "github.com/sirupsen/logrus" +) + +type driverLogLevel string + +const ( + DriverLogLevelDebug driverLogLevel = "DEBUG" + DriverLogLevelInfo driverLogLevel = "INFO" + DriverLogLevelWarn driverLogLevel = "WARN" + DriverLogLevelError driverLogLevel = "ERROR" + DriverLogLevelOff driverLogLevel = "OFF" +) + +var driverLogLevelMap = map[driverLogLevel]int{ + DriverLogLevelDebug: 1, + DriverLogLevelInfo: 2, + DriverLogLevelWarn: 3, + DriverLogLevelError: 4, + DriverLogLevelOff: 5, +} + +// wrappedLogger is a wrapper around the logrus logger provided to the plugin +// to satisfy the gocql.Logger interface. It simply forwards log messages from the +// gocql driver to the provided logrus logger. +// +// This logger can be used to capture and redirect logs from the gocql driver +// to the application's logging system, allowing for consistent log formatting +// and handling across the application. +type wrappedLogger struct { + logger logrus.FieldLogger + level driverLogLevel +} + +// Error logs an error message with the provided fields. +func (w *wrappedLogger) Error(msg string, fields ...gocql.LogField) { + if driverLogLevelMap[w.level] > driverLogLevelMap[DriverLogLevelError] { + return + } + + l := w.logger.WithFields(logrus.Fields{}) + + for _, field := range fields { + l = l.WithField(field.Name, field.Value) + } + l.Errorf("gocql-driver: %s", msg) +} + +// Warning logs a warning message with the provided fields. +func (w *wrappedLogger) Warning(msg string, fields ...gocql.LogField) { + if driverLogLevelMap[w.level] > driverLogLevelMap[DriverLogLevelWarn] { + return + } + + l := w.logger.WithFields(logrus.Fields{}) + + for _, field := range fields { + l = l.WithField(field.Name, field.Value) + } + l.Warnf("gocql-driver: %s", msg) +} + +// Info logs an informational message with the provided fields. +func (w *wrappedLogger) Info(msg string, fields ...gocql.LogField) { + if driverLogLevelMap[w.level] > driverLogLevelMap[DriverLogLevelInfo] { + return + } + + l := w.logger.WithFields(logrus.Fields{}) + + for _, field := range fields { + l = l.WithField(field.Name, field.Value) + } + l.Infof("gocql-driver: %s", msg) +} + +// Debug logs a debug message with the provided fields. +func (w *wrappedLogger) Debug(msg string, fields ...gocql.LogField) { + if driverLogLevelMap[w.level] > driverLogLevelMap[DriverLogLevelDebug] { + return + } + + l := w.logger.WithFields(logrus.Fields{}) + + for _, field := range fields { + l = l.WithField(field.Name, field.Value) + } + l.Debugf("gocql-driver: %s", msg) +} diff --git a/pkg/server/datastore/cassandra/index_utils.go b/pkg/server/datastore/cassandra/index_utils.go new file mode 100644 index 0000000000..bac6922f99 --- /dev/null +++ b/pkg/server/datastore/cassandra/index_utils.go @@ -0,0 +1,391 @@ +package cassandra + +import ( + "math" + "strings" + + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + "github.com/tjons/cassandra-toolbox/qb" +) + +func powerSlice[T any](els []T) [][]T { + if len(els) == 0 { + return [][]T{} + } + + results := [][]T{} + + count := int(math.Pow(2, float64(len(els)))) + + for i := range count { + subset := []T{} + + for j := range len(els) { + if (i & (1 << j)) > 0 { + subset = append(subset, els[j]) + } + } + + if len(subset) > 0 { + results = append(results, subset) + } + } + + return results +} + +func generateSelectorFilters(req *datastorev1.BySelectors, q qb.SelectBuilder) (needsExtraColumn bool) { + if req == nil { + return + } + + switch req.MatchBehavior { + case datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_EXACT: + q.Where("index_terms", qb.Contains(buildSelectorMatchExactIndex(req.Selectors))) + q.Distinct() + case datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_SUBSET: + selectors := make([]any, len(req.Selectors)) + + for i, sl := range req.Selectors { + b := strings.Builder{} + b.WriteString(sl.Type) + b.WriteString("|") + b.WriteString(sl.Value) + selectors[i] = b.String() + } + + vals := powerSlice(selectors) + + q.Where("selector_type_value_full", qb.CollectionIn(vals...)) + q.Distinct() + case datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_ANY: + vals := make([]any, len(req.Selectors)) + for i, sl := range req.Selectors { + b := strings.Builder{} + b.WriteString(sl.Type) + b.WriteString("|") + b.WriteString(sl.Value) + vals[i] = b.String() + } + + q.Where("selector_type_value", qb.In(vals...)) + return true + case datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_SUPERSET: + b := strings.Builder{} + b.WriteString(selectorMatchPrefix) + b.WriteString(matcherSupersetInfix) + + for i, sl := range req.Selectors { + if i > 0 { + b.WriteString("__") + } + b.WriteString("type_") + b.WriteString(sl.Type) + b.WriteString("_value_") + b.WriteString(sl.Value) + } + + q.Distinct().Where("index_terms", qb.Contains(b.String())) + } + + return +} + +func generateSearchIndexesForRequest(req *datastorev1.ListRegistrationEntriesRequest) []queryTerm { + var indices []queryTerm + if req.BySelectors != nil { + switch req.BySelectors.MatchBehavior { + case datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_EXACT: + indices = append(indices, queryTerm{ + field: "index_terms", + operator: "CONTAINS", + values: []any{buildSelectorMatchExactIndex(req.BySelectors.Selectors)}, + requireDistinct: true, + }) + case datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_SUBSET: + selectors := make([]any, len(req.BySelectors.Selectors)) + + for i, sl := range req.BySelectors.Selectors { + b := strings.Builder{} + b.WriteString(sl.Type) + b.WriteString("|") + b.WriteString(sl.Value) + selectors[i] = b.String() + } + + vals := powerSlice(selectors) + + indices = append(indices, queryTerm{ + field: "selector_type_value_full", + operator: "IN", + deepValues: vals, + requireDistinct: true, + }) + case datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_ANY: + vals := make([]any, len(req.BySelectors.Selectors)) + for i, sl := range req.BySelectors.Selectors { + b := strings.Builder{} + b.WriteString(sl.Type) + b.WriteString("|") + b.WriteString(sl.Value) + vals[i] = b.String() + } + + indices = append(indices, queryTerm{ + field: "unrolled_selector_type_val", + operator: "IN", + values: vals, + includeExtraColumn: true, + }) + case datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_SUPERSET: + b := strings.Builder{} + b.WriteString(selectorMatchPrefix) + b.WriteString(matcherSupersetInfix) + + for i, sl := range req.BySelectors.Selectors { + if i > 0 { + b.WriteString("__") + } + b.WriteString("type_") + b.WriteString(sl.Type) + b.WriteString("_value_") + b.WriteString(sl.Value) + } + + indices = append(indices, queryTerm{ + field: "index_terms", + operator: "CONTAINS", + values: []any{b.String()}, + requireDistinct: true, + }) + } + } + + if req.ByFederatesWith != nil { + switch req.ByFederatesWith.MatchBehavior { + case datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_EXACT: + indices = append(indices, queryTerm{ + field: "index_terms", + operator: "CONTAINS", + values: []any{buildFtdExactIndex(req.ByFederatesWith.FederatesWith)}, + requireDistinct: true, + }) + case datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_SUBSET: + tds := make([]any, len(req.ByFederatesWith.FederatesWith)) + for i, td := range req.ByFederatesWith.FederatesWith { + tds[i] = td + } + + vals := powerSlice(tds) + indices = append(indices, queryTerm{ + field: "federated_trust_domains_full", + operator: "IN", + deepValues: vals, + requireDistinct: true, + }) + case datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_ANY: + vals := make([]any, len(req.ByFederatesWith.FederatesWith)) + for i, td := range req.ByFederatesWith.FederatesWith { + vals[i] = td + } + + indices = append(indices, queryTerm{ + field: "unrolled_ftd", + operator: "IN", + values: vals, + includeExtraColumn: true, + }) + case datastorev1.MatchBehavior_MATCH_BEHAVIOR_MATCH_SUPERSET: + b := strings.Builder{} + b.WriteString(ftdIndexPrefix) + b.WriteString(matcherSupersetInfix) + for i, td := range req.ByFederatesWith.FederatesWith { + if i > 0 { + b.WriteString("__") + } + b.WriteString("td_") + b.WriteString(td) + } + + indices = append(indices, queryTerm{ + field: "index_terms", + operator: "CONTAINS", + values: []any{b.String()}, + requireDistinct: true, + }) + } + } + + return indices +} + +const ftdIndexPrefix = "ftd_" +const multipartIndexPrefix = "mpidx__" + +func buildIndexesForRegistrationEntry(re *datastorev1.RegistrationEntry) (indexes []string) { + var sls, tds []string + if len(re.GetSelectors()) > 0 { + sls = buildSelectorIndexes(re.GetSelectors()) + indexes = append(indexes, sls...) + } + + if len(re.GetFederatesWith()) > 0 { + tds = buildFtdIndexes(re.GetFederatesWith()) + indexes = append(indexes, tds...) + } + + for _, s := range sls { + for _, t := range tds { + b := strings.Builder{} + b.WriteString(multipartIndexPrefix) + b.WriteString(s) + b.WriteString("___") + b.WriteString(t) + + indexes = append(indexes, b.String()) + } + } + + return +} + +func buildFtdIndexes(trustDomains []string) (indexes []string) { + indexes = append(indexes, buildFtdAnyMatchIndexes(trustDomains)...) + indexes = append(indexes, buildFtdExactIndex(trustDomains)) + indexes = append(indexes, buildFtdSupersetMatchIndexes(trustDomains)...) + + return +} + +func buildFtdExactIndex(trustDomains []string) string { + b := strings.Builder{} + b.WriteString(ftdIndexPrefix) + b.WriteString(matcherExactInfix) + for i, td := range trustDomains { + if i > 0 { + b.WriteString("__") + } + b.WriteString("td_") + b.WriteString(td) + } + + return b.String() +} + +func buildFtdAnyMatchIndexes(trustDomains []string) []string { + indexes := make([]string, 0, len(trustDomains)) + + for _, td := range trustDomains { + b := strings.Builder{} + b.WriteString(ftdIndexPrefix) + b.WriteString(matcherAnyInfix) + b.WriteString("td_") + b.WriteString(td) + + indexes = append(indexes, b.String()) + } + + return indexes +} + +func buildFtdSupersetMatchIndexes(trustDomains []string) []string { + powerset := powerSlice(trustDomains) + + indexes := make([]string, 0, len(trustDomains)) + + for _, subset := range powerset { + b := strings.Builder{} + b.WriteString(ftdIndexPrefix) + b.WriteString(matcherSupersetInfix) + + for i, sub := range subset { + if i > 0 { + b.WriteString("__") + } + b.WriteString("td_") + b.WriteString(sub) + } + + indexes = append(indexes, b.String()) + } + + return indexes +} + +func buildSelectorIndexes(selectors []*datastorev1.Selector) (indexes []string) { + indexes = append(indexes, buildSelectorAnyMatchIndexes(selectors)...) + indexes = append(indexes, buildSelectorMatchExactIndex(selectors)) + indexes = append(indexes, buildSelectorSupersetMatchIndexes(selectors)...) + // subset is implemented as "in these, but no others": see filterEntriesBySelectorSet in pkg/server/datastore/sqlstore/sqlstore.go + + return +} + +const ( + selectorMatchPrefix = "stv_" + matcherAnyInfix = "match_any_" + matcherExactInfix = "match_exact_" + matcherSupersetInfix = "match_superset_" +) + +func buildSelectorAnyMatchIndexes(selectors []*datastorev1.Selector) []string { + indexes := make([]string, 0, len(selectors)) + + for _, s := range selectors { + b := strings.Builder{} + b.WriteString(selectorMatchPrefix) + b.WriteString(matcherAnyInfix) + b.WriteString("type_") + b.WriteString(s.Type) + b.WriteString("_value_") + b.WriteString(s.Value) + + indexes = append(indexes, b.String()) + } + + return indexes +} + +func buildSelectorMatchExactIndex(selectors []*datastorev1.Selector) string { + b := strings.Builder{} + b.WriteString(selectorMatchPrefix) + b.WriteString(matcherExactInfix) + + for i, s := range selectors { + if i > 0 { + b.WriteString("__") + } + b.WriteString("type_") + b.WriteString(s.Type) + b.WriteString("_value_") + b.WriteString(s.Value) + } + + return b.String() +} + +func buildSelectorSupersetMatchIndexes(selectors []*datastorev1.Selector) []string { + powerset := powerSlice(selectors) + + indexes := make([]string, 0, len(selectors)) + + for _, subset := range powerset { + b := strings.Builder{} + b.WriteString(selectorMatchPrefix) + b.WriteString(matcherSupersetInfix) + + for i, sub := range subset { + if i > 0 { + b.WriteString("__") + } + b.WriteString("type_") + b.WriteString(sub.Type) + b.WriteString("_value_") + b.WriteString(sub.Value) + } + + indexes = append(indexes, b.String()) + } + + return indexes +} diff --git a/pkg/server/datastore/cassandra/index_utils_test.go b/pkg/server/datastore/cassandra/index_utils_test.go new file mode 100644 index 0000000000..a3b022eaf1 --- /dev/null +++ b/pkg/server/datastore/cassandra/index_utils_test.go @@ -0,0 +1,186 @@ +package cassandra + +import ( + "slices" + "testing" + + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" +) + +func TestMatchAnySelectorIndexes(t *testing.T) { + selectors := []*datastorev1.Selector{ + {Type: "a", Value: "1"}, + {Type: "b", Value: "2"}, + {Type: "c", Value: "3"}, + } + + expectedAnyIndexes := []string{ + "stv_match_any_type_a_value_1", + "stv_match_any_type_b_value_2", + "stv_match_any_type_c_value_3", + } + + actualAnyIndexes := buildSelectorAnyMatchIndexes(selectors) + + if len(expectedAnyIndexes) != len(actualAnyIndexes) { + t.Fatalf("expected %d any match indexes, got %d", len(expectedAnyIndexes), len(actualAnyIndexes)) + } + + for i, expected := range expectedAnyIndexes { + if expected != actualAnyIndexes[i] { + t.Errorf("expected any match index %d to be %q, got %q", i, expected, actualAnyIndexes[i]) + } + } +} + +func TestMatchExactSelectorIndex(t *testing.T) { + selectors := []*datastorev1.Selector{ + {Type: "a", Value: "1"}, + } + + expectedExactIndex := "stv_match_exact_type_a_value_1" + actualExactIndex := buildSelectorMatchExactIndex(selectors) + + if expectedExactIndex != actualExactIndex { + t.Errorf("expected exact match index to be %q, got %q", expectedExactIndex, actualExactIndex) + } +} + +func TestMatchSupersetSelectorIndexes(t *testing.T) { + selectors := []*datastorev1.Selector{ + {Type: "a", Value: "1"}, + {Type: "b", Value: "2"}, + } + + expectedSupersetIndexes := []string{ + "stv_match_superset_type_a_value_1", + "stv_match_superset_type_b_value_2", + "stv_match_superset_type_a_value_1_type_b_value_2", + } + + actualSupersetIndexes := buildSelectorSupersetMatchIndexes(selectors) + + if len(expectedSupersetIndexes) != len(actualSupersetIndexes) { + t.Fatalf("expected %d superset match indexes, got %d", len(expectedSupersetIndexes), len(actualSupersetIndexes)) + } + + for i, expected := range expectedSupersetIndexes { + if expected != actualSupersetIndexes[i] { + t.Errorf("expected superset match index %d to be %q, got %q", i, expected, actualSupersetIndexes[i]) + } + } +} + +func TestMatchExactFederatedTrustDomainIndex(t *testing.T) { + trustDomains := []string{ + "domain1.test", + "domain2.test", + "domain3.test", + } + expected := "ftd_match_exact_td_domain1.test__td_domain2.test__td_domain3.test" + actual := buildFtdExactIndex(trustDomains) + + if expected != actual { + t.Errorf("expected federated trust domain exact match index to be %q, got %q", expected, actual) + } +} + +func TestMatchAnyFederatedTrustDomainIndexes(t *testing.T) { + trustDomains := []string{ + "domain1.test", + "domain2.test", + "domain3.test", + } + + expected := []string{ + "ftd_match_any_td_domain1.test", + "ftd_match_any_td_domain2.test", + "ftd_match_any_td_domain3.test", + } + + actual := buildFtdAnyMatchIndexes(trustDomains) + + if len(expected) != len(actual) { + t.Fatalf("expected %d federated trust domain any match indexes, got %d", len(expected), len(actual)) + } + + for i, exp := range expected { + if exp != actual[i] { + t.Errorf("expected federated trust domain any match index %d to be %q, got %q", i, exp, actual[i]) + } + } +} + +func TestMatchSupersetFederatedTrustDomainIndexes(t *testing.T) { + trustDomains := []string{ + "domain1.test", + "domain2.test", + } + + expected := []string{ + "ftd_match_superset_td_domain1.test", + "ftd_match_superset_td_domain2.test", + "ftd_match_superset_td_domain1.test__td_domain2.test", + } + + actual := buildFtdSupersetMatchIndexes(trustDomains) + + if len(expected) != len(actual) { + t.Fatalf("expected %d federated trust domain superset match indexes, got %d", len(expected), len(actual)) + } + + for i, exp := range expected { + if exp != actual[i] { + t.Errorf("expected federated trust domain superset match index %d to be %q, got %q", i, exp, actual[i]) + } + } +} + +func TestCombinations(t *testing.T) { + cases := []struct { + els []string + expected [][]string + }{ + { + els: []string{"a", "b", "c", "d"}, + expected: [][]string{ + {"a"}, + {"b"}, + {"c"}, + {"d"}, + {"a", "b"}, + {"a", "c"}, + {"a", "d"}, + {"a", "b", "c"}, + {"a", "c", "d"}, + {"a", "b", "d"}, + {"a", "b", "c", "d"}, + {"b", "c"}, + {"b", "d"}, + {"b", "c", "d"}, + {"c", "d"}, + }, + }, + } + + for _, c := range cases { + ret := powerSlice(c.els) + if len(ret) != len(c.expected) { + t.Fatalf("unxepected length") + } + + for _, v := range ret { + expected := false + for _, w := range c.expected { + if slices.Equal(w, v) { + expected = true + break + } + } + + if !expected { + t.Fatalf("expected to find %v in %v", v, c.expected) + } + } + } +} diff --git a/pkg/server/datastore/cassandra/keys.go b/pkg/server/datastore/cassandra/keys.go new file mode 100644 index 0000000000..2dff11fd5f --- /dev/null +++ b/pkg/server/datastore/cassandra/keys.go @@ -0,0 +1,240 @@ +package cassandra + +import ( + "context" + "crypto/x509" + + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + "github.com/spiffe/spire/pkg/common/x509util" + "github.com/spiffe/spire/proto/spire/common" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Effectively a 1:1 copy from pkg/server/datastore/sqlstore/sqlstore.go:1451 +func (p *Plugin) TaintX509CA(ctx context.Context, req *datastorev1.TaintX509CARequest) (*datastorev1.TaintX509CAResponse, error) { + bundleResp, err := p.FetchBundle(ctx, &datastorev1.FetchBundleRequest{TrustDomain: req.TrustDomain}) + if err != nil { + return nil, err + } + if bundleResp.Bundle == nil { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + + commonBundle, err := dataToBundle(bundleResp.Bundle.Data) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to parse bundle data: %v", err) + } + + found := false + for _, eachRootCA := range commonBundle.RootCas { + x509CA, err := x509.ParseCertificate(eachRootCA.DerBytes) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to parse rootCA: %v", err) + } + + caSubjectKeyID := x509util.SubjectKeyIDToString(x509CA.SubjectKeyId) + if req.KeyId != caSubjectKeyID { + continue + } + + if eachRootCA.TaintedKey { + return nil, status.Errorf(codes.InvalidArgument, "root CA is already tainted") + } + + found = true + eachRootCA.TaintedKey = true + } + + if !found { + return nil, status.Error(codes.NotFound, "no ca found with provided subject key ID") + } + + commonBundle.SequenceNumber++ + + dataBundle, err := bundleToModel(commonBundle) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to serialize bundle data: %v", err) + } + _, err = p.updateBundle(ctx, &datastorev1.UpdateBundleRequest{ + Bundle: dataBundle, + }) + return &datastorev1.TaintX509CAResponse{}, err +} + +// Effectively a 1:1 copy from pkg/server/datastore/sqlstore/sqlstore.go:1488 +func (p *Plugin) RevokeX509CA(ctx context.Context, req *datastorev1.RevokeX509CARequest) (*datastorev1.RevokeX509CAResponse, error) { + bundleResp, err := p.FetchBundle(ctx, &datastorev1.FetchBundleRequest{ + TrustDomain: req.TrustDomain, + }) + if err != nil { + return nil, err + } + if bundleResp.GetBundle() == nil { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + + bundle, err := dataToBundle(bundleResp.Bundle.Data) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to parse bundle data: %v", err) + } + + keyFound := false + var rootCAs []*common.Certificate + for _, ca := range bundle.RootCas { + cert, err := x509.ParseCertificate(ca.DerBytes) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to parse root CA: %v", err) + } + + caSubjectKeyID := x509util.SubjectKeyIDToString(cert.SubjectKeyId) + if req.KeyId == caSubjectKeyID { + if !ca.TaintedKey { + return nil, status.Error(codes.InvalidArgument, "it is not possible to revoke an untainted root CA") + } + keyFound = true + continue + } + + rootCAs = append(rootCAs, ca) + } + + if !keyFound { + return nil, status.Error(codes.NotFound, "no root CA found with provided subject key ID") + } + + bundle.RootCas = rootCAs + bundle.SequenceNumber++ + + modelBundle, err := bundleToModel(bundle) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to serialize bundle data: %v", err) + } + + if _, err := p.updateBundle(ctx, &datastorev1.UpdateBundleRequest{ + Bundle: modelBundle, + }); err != nil { + return nil, status.Errorf(codes.Internal, "failed to update bundle: %v", err) + } + + return &datastorev1.RevokeX509CAResponse{}, nil +} + +// Effectively a 1:1 copy from pkg/server/datastore/sqlstore/sqlstore.go:1527 +func (p *Plugin) TaintJWTKey(ctx context.Context, req *datastorev1.TaintJWTKeyRequest) (*datastorev1.TaintJWTKeyResponse, error) { + bundleResp, err := p.FetchBundle(ctx, &datastorev1.FetchBundleRequest{TrustDomain: req.GetTrustDomain()}) + if err != nil { + return nil, err + } + if bundleResp.GetBundle() == nil { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + + bundle, err := dataToBundle(bundleResp.Bundle.Data) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to parse bundle data: %v", err) + } + + var taintedKey *common.PublicKey + for _, jwtKey := range bundle.JwtSigningKeys { + if jwtKey.Kid != req.AuthorityId { + continue + } + + if jwtKey.TaintedKey { + return nil, status.Error(codes.InvalidArgument, "key is already tainted") + } + + // Check if a JWT Key with the provided keyID was already + // tainted in this loop. This is purely defensive since we do not + // allow to have repeated key IDs. + if taintedKey != nil { + return nil, status.Error(codes.Internal, "another JWT Key found with the same KeyID") + } + taintedKey = jwtKey + jwtKey.TaintedKey = true + } + + if taintedKey == nil { + return nil, status.Error(codes.NotFound, "no JWT Key found with provided key ID") + } + + bundle.SequenceNumber++ + + modelBundle, err := bundleToModel(bundle) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to serialize bundle data: %v", err) + } + if _, err := p.updateBundle(ctx, &datastorev1.UpdateBundleRequest{ + Bundle: modelBundle, + }); err != nil { + return nil, err + } + + return &datastorev1.TaintJWTKeyResponse{Key: &datastorev1.PublicKey{ + Kid: taintedKey.Kid, + TaintedKey: taintedKey.TaintedKey, + NotAfter: taintedKey.NotAfter, + PkixBytes: taintedKey.PkixBytes, + }}, nil +} + +// Effectively a 1:1 copy from pkg/server/datastore/sqlstore/sqlstore.go:1565 +func (p *Plugin) RevokeJWTKey(ctx context.Context, req *datastorev1.RevokeJWTKeyRequest) (*datastorev1.RevokeJWTKeyResponse, error) { + bundleResp, err := p.FetchBundle(ctx, &datastorev1.FetchBundleRequest{TrustDomain: req.GetTrustDomain()}) + if err != nil { + return nil, err + } + if bundleResp.GetBundle() == nil { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + + bundle, err := dataToBundle(bundleResp.Bundle.Data) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to parse bundle data: %v", err) + } + + var publicKeys []*common.PublicKey + var revokedKey *common.PublicKey + for _, key := range bundle.JwtSigningKeys { + if key.Kid == req.AuthorityId { + // Check if a JWT Key with the provided keyID was already + // found in this loop. This is purely defensive since we do not + // allow to have repeated key IDs. + if revokedKey != nil { + return nil, status.Error(codes.Internal, "another key found with the same KeyID") + } + + if !key.TaintedKey { + return nil, status.Error(codes.InvalidArgument, "it is not possible to revoke an untainted key") + } + + revokedKey = key + continue + } + publicKeys = append(publicKeys, key) + } + bundle.JwtSigningKeys = publicKeys + + if revokedKey == nil { + return nil, status.Error(codes.NotFound, "no JWT Key found with provided key ID") + } + + bundle.SequenceNumber++ + modelBundle, err := bundleToModel(bundle) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to serialize bundle data: %v", err) + } + if _, err := p.updateBundle(ctx, &datastorev1.UpdateBundleRequest{ + Bundle: modelBundle, + }); err != nil { + return nil, err + } + + return &datastorev1.RevokeJWTKeyResponse{Key: &datastorev1.PublicKey{ + Kid: revokedKey.Kid, + TaintedKey: revokedKey.TaintedKey, + NotAfter: revokedKey.NotAfter, + PkixBytes: revokedKey.PkixBytes, + }}, nil +} diff --git a/pkg/server/datastore/cassandra/migrations.go b/pkg/server/datastore/cassandra/migrations.go new file mode 100644 index 0000000000..ce4df129ed --- /dev/null +++ b/pkg/server/datastore/cassandra/migrations.go @@ -0,0 +1,145 @@ +package cassandra + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + "github.com/spiffe/spire/pkg/server/datastore/cassandra/migrations" + migrator "github.com/tjons/cassandra-toolbox/migrate" +) + +func (p *Plugin) ensureKeyspaceExists(ctx context.Context, config *runtimeConfiguration) error { + bootstrapCluster := gocql.NewCluster(config.Hosts...) + + if config.Username != "" && config.Password != "" { + bootstrapCluster.Authenticator = gocql.PasswordAuthenticator{ + Username: config.Username, + Password: config.Password, + } + } + + bootstrapCluster.Consistency = gocql.One + bootstrapCluster.Logger = &wrappedLogger{logger: p.log, level: config.DriverLogLevel} + + if config.TLSConfig.RequireMTLS() { + // Verify that the files can be read before attempting to use them for TLS configuration, to fail fast if there are any issues with the provided paths or files. + clientCert, err := os.ReadFile(config.TLSConfig.ClientCertPath) + if err != nil { + return fmt.Errorf("unable to read client certificate: %w", err) + } + if len(clientCert) == 0 { + return fmt.Errorf("client certificate file is empty: %s", config.TLSConfig.ClientCertPath) + } + + clientKey, err := os.ReadFile(config.TLSConfig.ClientKeyPath) + if err != nil { + return fmt.Errorf("unable to read client key: %w", err) + } + if len(clientKey) == 0 { + return fmt.Errorf("client key file is empty: %s", config.TLSConfig.ClientKeyPath) + } + + rootCA, err := os.ReadFile(config.TLSConfig.RootCAPath) + if err != nil { + return fmt.Errorf("unable to read root CA certificate: %w", err) + } + if len(rootCA) == 0 { + return fmt.Errorf("root CA certificate file is empty: %s", config.TLSConfig.RootCAPath) + } + + bootstrapCluster.SslOpts = &gocql.SslOptions{ + EnableHostVerification: true, + CertPath: config.TLSConfig.ClientCertPath, + KeyPath: config.TLSConfig.ClientKeyPath, + CaPath: config.TLSConfig.RootCAPath, + } + } else if config.TLSConfig.RequireTLS() { + // Verify that the file can be read before attempting to use it for TLS configuration, to fail fast if there are any issues with the provided path or file. + rootCA, err := os.ReadFile(config.TLSConfig.RootCAPath) + if err != nil { + return fmt.Errorf("unable to read root CA certificate: %w", err) + } + if len(rootCA) == 0 { + return fmt.Errorf("root CA certificate file is empty: %s", config.TLSConfig.RootCAPath) + } + + bootstrapCluster.SslOpts = &gocql.SslOptions{ + EnableHostVerification: true, + CaPath: config.TLSConfig.RootCAPath, + } + } + + bootstrapSession, err := bootstrapCluster.CreateSession() + if err != nil { + return err + } + defer bootstrapSession.Close() + + const listKeyspacesQuery = "SELECT keyspace_name FROM system_schema.keyspaces" + iter := bootstrapSession.Query(listKeyspacesQuery).IterContext(ctx) + + var keyspaceName string + for iter.Scan(&keyspaceName) { + if keyspaceName == config.Keyspace { + return nil + } + } + + if err := p.createKeyspace(ctx, config, bootstrapSession); err != nil { + return err + } + + return nil +} + +func (p *Plugin) createKeyspace(ctx context.Context, config *runtimeConfiguration, session *gocql.Session) error { + queryBuilder := strings.Builder{} + // use IF NOT EXISTS here because in an HA setup, multiple SPIRE server replicas + // may attempt to create the keyspace simultaneously. + queryBuilder.WriteString("CREATE KEYSPACE IF NOT EXISTS ") + queryBuilder.WriteString(config.Keyspace) + queryBuilder.WriteString(" WITH REPLICATION = {'class': '") + + switch config.ReplicationStrategy { + case SimpleStrategy: + queryBuilder.WriteString("SimpleStrategy', 'replication_factor': ") + queryBuilder.WriteString(strconv.Itoa(config.SimpleStrategyReplicationFactor)) + case NetworkTopologyStrategy: + addNetworkTopologyStrategyReplicationOptions(&queryBuilder, config.NetworkTopologyStrategyReplicationFactors) + } + + queryBuilder.WriteString("}") + query := queryBuilder.String() + + if err := session.Query(query).ExecContext(ctx); err != nil { + return fmt.Errorf("failed to create keyspace: %w", err) + } + + return nil +} + +func addNetworkTopologyStrategyReplicationOptions(queryBuilder *strings.Builder, datacenters map[string]int) { + first := true + queryBuilder.WriteString("NetworkTopologyStrategy', ") + + for dc, rf := range datacenters { + if !first { + queryBuilder.WriteString(", ") + } + queryBuilder.WriteString("'") + queryBuilder.WriteString(dc) + queryBuilder.WriteString("': ") + queryBuilder.WriteString(strconv.Itoa(rf)) + + first = false + } + +} + +func (p *Plugin) applyMigrations(session *gocql.Session) error { + return migrator.RunMigrations(context.Background(), p.cfg.Keyspace, session, migrations.Migrations) +} diff --git a/pkg/server/datastore/cassandra/migrations/001-spire-schema.cql b/pkg/server/datastore/cassandra/migrations/001-spire-schema.cql new file mode 100644 index 0000000000..2f4dd17eaa --- /dev/null +++ b/pkg/server/datastore/cassandra/migrations/001-spire-schema.cql @@ -0,0 +1,119 @@ +CREATE TABLE IF NOT EXISTS registered_entries ( + created_at timestamp STATIC, + updated_at timestamp, --- not static for exotic cassandra big-brain reasons + entry_id text, + spiffe_id text STATIC, + parent_id text STATIC, + ttl int STATIC, + admin boolean STATIC, + downstream boolean STATIC, + expiry bigint STATIC, + revision_number bigint STATIC, + store_svid boolean STATIC, + hint text STATIC, + jwt_svid_ttl int STATIC, + selector_types frozen> STATIC, + selector_values frozen> STATIC, + unrolled_selector_type_val text, + unrolled_ftd text, + dns_names set STATIC, + federated_trust_domains set STATIC, + index_terms set STATIC, + selector_type_value_full frozen> STATIC, + federated_trust_domains_full frozen> STATIC, + PRIMARY KEY (entry_id, unrolled_selector_type_val, unrolled_ftd) +); + +CREATE INDEX IF NOT EXISTS parent_id_idx ON registered_entries (parent_id) USING 'sai'; +CREATE INDEX IF NOT EXISTS spiffe_id_idx ON registered_entries (spiffe_id) USING 'sai'; +CREATE INDEX IF NOT EXISTS hint_idx ON registered_entries (hint) USING 'sai'; +CREATE INDEX IF NOT EXISTS federated_trust_domain_idx ON registered_entries (federated_trust_domains) USING 'sai'; +CREATE INDEX IF NOT EXISTS downstream_idx ON registered_entries (downstream) USING 'sai'; +CREATE INDEX IF NOT EXISTS expiry_idx ON registered_entries (expiry) USING 'sai'; +CREATE INDEX IF NOT EXISTS indices_idx ON registered_entries (index_terms) USING 'sai'; +CREATE INDEX IF NOT EXISTS unrolled_ftd_idx ON registered_entries (unrolled_ftd) USING 'sai'; +CREATE INDEX IF NOT EXISTS unrolled_selector_type_val_idx ON registered_entries (unrolled_selector_type_val) USING 'sai'; +CREATE INDEX IF NOT EXISTS selector_type_value_full_idx ON registered_entries (FULL(selector_type_value_full)) USING 'sai'; +CREATE INDEX IF NOT EXISTS federated_trust_domains_full_idx ON registered_entries (FULL(federated_trust_domains_full)) USING 'sai'; + +CREATE TABLE IF NOT EXISTS registration_entry_events ( + id int, + created_at timestamp, + updated_at timestamp, + entry_id varchar, + PRIMARY KEY(entry_id, id) +); +CREATE INDEX IF NOT EXISTS registration_entry_events_entry_id_idx ON registration_entry_events (id) USING 'sai'; + +CREATE TABLE IF NOT EXISTS attested_node_entries ( + created_at timestamp STATIC, + updated_at timestamp, + spiffe_id varchar, + attestation_data_type varchar STATIC, + serial_number varchar STATIC, + banned boolean STATIC, + cert_not_after bigint STATIC, + new_serial_number varchar STATIC, + new_cert_not_after bigint STATIC, + can_reattest boolean STATIC, + agent_version varchar STATIC, + selector_type_value varchar, + selector_type_value_full frozen> STATIC, + index_terms set STATIC, + PRIMARY KEY (spiffe_id, selector_type_value) +); + +CREATE INDEX IF NOT EXISTS cert_not_after_idx ON attested_node_entries (cert_not_after) USING 'sai'; +CREATE INDEX IF NOT EXISTS selector_type_value_idx ON attested_node_entries (selector_type_value) USING 'sai'; +CREATE INDEX IF NOT EXISTS selector_type_value_full_idx ON attested_node_entries (FULL(selector_type_value_full)) USING 'sai'; +CREATE INDEX IF NOT EXISTS serial_number_idx ON attested_node_entries (serial_number) USING 'sai'; +CREATE INDEX IF NOT EXISTS banned_idx ON attested_node_entries (banned) USING 'sai'; +CREATE INDEX IF NOT EXISTS index_terms_idx ON attested_node_entries (index_terms) USING 'sai'; + +CREATE TABLE IF NOT EXISTS attested_node_entries_events ( + event_id int, + created_at timestamp, + updated_at timestamp, + spiffe_id varchar, + PRIMARY KEY (spiffe_id, event_id) +); +CREATE INDEX IF NOT EXISTS attested_node_entries_events_event_id_idx ON attested_node_entries_events (event_id) USING 'sai'; +CREATE INDEX IF NOT EXISTS attested_node_entries_events_created_at_idx ON attested_node_entries_events (created_at) USING 'sai'; + +CREATE TABLE IF NOT EXISTS bundles ( + created_at timestamp STATIC, + updated_at timestamp STATIC, + trust_domain varchar, + data blob STATIC, --- this is static because the bundle data is the same across all rows + federated_entry_id varchar, + PRIMARY KEY (trust_domain, federated_entry_id), +); +CREATE INDEX IF NOT EXISTS federated_entry_id_idx ON bundles (federated_entry_id) USING 'sai'; + +CREATE TABLE IF NOT EXISTS ca_journals ( + id int, + created_at timestamp, + updated_at timestamp, + data blob, + active_x509_authority_id varchar, + PRIMARY KEY (id) +); +CREATE INDEX IF NOT EXISTS ca_journals_active_idx ON ca_journals (active_x509_authority_id) USING 'sai'; + +CREATE TABLE IF NOT EXISTS federated_trust_domains ( + created_at timestamp, + updated_at timestamp, + trust_domain varchar, + bundle_endpoint_url varchar, + bundle_endpoint_profile varchar, + endpoint_spiffe_id varchar, + -- implicit boolean, --- pretty sure this can be dropped in upstream sqlstore + PRIMARY KEY (trust_domain) +); + +CREATE TABLE IF NOT EXISTS join_tokens ( + join_token varchar, + expiry bigint, + PRIMARY KEY (join_token) +); +CREATE INDEX IF NOT EXISTS expiry_idx ON join_tokens (expiry) USING 'sai'; \ No newline at end of file diff --git a/pkg/server/datastore/cassandra/migrations/embed.go b/pkg/server/datastore/cassandra/migrations/embed.go new file mode 100644 index 0000000000..65dad7aedd --- /dev/null +++ b/pkg/server/datastore/cassandra/migrations/embed.go @@ -0,0 +1,8 @@ +package migrations + +import ( + "embed" +) + +//go:embed *.cql +var Migrations embed.FS diff --git a/pkg/server/datastore/cassandra/nodes.go b/pkg/server/datastore/cassandra/nodes.go new file mode 100644 index 0000000000..818b7ebc38 --- /dev/null +++ b/pkg/server/datastore/cassandra/nodes.go @@ -0,0 +1,701 @@ +package cassandra + +import ( + "context" + "errors" + "maps" + "slices" + "time" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + "github.com/tjons/cassandra-toolbox/qb" + "github.com/tjons/cassandra-toolbox/qb/pages" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (p *Plugin) CountAttestedNodes(ctx context.Context, req *datastorev1.CountAttestedNodesRequest) (*datastorev1.CountAttestedNodesResponse, error) { + if req == nil { + return nil, status.Error(codes.InvalidArgument, "request is required") + } + + if req.BySelectors != nil && len(req.BySelectors.Selectors) == 0 { + return nil, status.Error(codes.InvalidArgument, "cannot count by empty selectors set") + } + + q := qb.NewSelect(). + Distinct(). + Column("spiffe_id"). + Column("COUNT(*)"). + From("attested_node_entries"). + AllowFiltering() + + if req.ByBanned { + if req.BannedValue { + // The original SQL implementation marks nodes as "banned" by setting + // their serial number to an empty string. However, since Cassandra + // does not support filtering with "!=" operator, we add a dedicated + // "banned" boolean column to simplify queries. + q.Where("banned", qb.Equals(true)) + } else { + q.Where("banned", qb.Equals(false)) + } + } + if req.ByAttestationType != "" { + q.Where("attestation_data_type", qb.Equals(req.ByAttestationType)) + } + if req.ByExpiresBefore > 0 { + q.Where("cert_not_after", qb.LessThan(req.ByExpiresBefore)) + } + if req.ByCanReattest { + q.Where("can_reattest", qb.Equals(req.ByCanReattest)) + } + if req.BySelectors != nil { + _ = generateSelectorFilters(req.BySelectors, q) // we don't care about the distinguishing column here + } + + var ( + count int32 + spiffeIDunused string + ) + if err := p.db.ReadQuery(q).ScanContext(ctx, &spiffeIDunused, &count); err != nil { + return nil, newWrappedCassandraError(err) + } + + return &datastorev1.CountAttestedNodesResponse{ + Count: count, + }, nil +} + +func (p *Plugin) CreateAttestedNode(ctx context.Context, req *datastorev1.CreateAttestedNodeRequest) (*datastorev1.CreateAttestedNodeResponse, error) { + if req == nil || req.Node == nil { + return nil, newCassandraError("invalid request: missing attested node") + } + + newAttestedNode := &datastorev1.AttestedNode{ + SpiffeId: req.Node.SpiffeId, + AttestationDataType: req.Node.AttestationDataType, + CertSerialNumber: req.Node.CertSerialNumber, + CertNotAfter: req.Node.CertNotAfter, + NewCertSerialNumber: req.Node.NewCertSerialNumber, + CanReattest: req.Node.CanReattest, + Selectors: req.Node.Selectors, + } + + if req.Node.NewCertNotAfter != 0 { + newAttestedNode.NewCertNotAfter = req.Node.NewCertNotAfter + } + + createdNode, err := p.createAttestedNode(ctx, newAttestedNode) + if err != nil { + return nil, err + } + + return &datastorev1.CreateAttestedNodeResponse{ + Node: createdNode, + }, nil +} + +func (p *Plugin) createAttestedNode(ctx context.Context, model *datastorev1.AttestedNode) (*datastorev1.AttestedNode, error) { + createAttestedNodeQuery := ` + INSERT INTO attested_node_entries ( + created_at, + updated_at, + spiffe_id, + attestation_data_type, + serial_number, + cert_not_after, + new_serial_number, + new_cert_not_after, + can_reattest, + agent_version, + selector_type_value, + banned + ) VALUES (toTimestamp(now()), toTimestamp(now()), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + + if err := p.db.session.Query( + createAttestedNodeQuery, + model.GetSpiffeId(), + model.GetAttestationDataType(), + model.GetCertSerialNumber(), + model.GetCertNotAfter(), + model.GetNewCertSerialNumber(), + model.GetNewCertNotAfter(), + model.GetCanReattest(), + model.GetAgentVersion(), + "", + model.GetCertSerialNumber() == "", + ).ExecContext(ctx); err != nil { + return nil, newWrappedCassandraError(err) + } + + model.CreatedAt = time.Now().Unix() + model.UpdatedAt = model.CreatedAt + + err := p.createAttestedNodeEvent(ctx, &datastorev1.AttestedNodeEvent{ + SpiffeId: model.GetSpiffeId(), + }) + + return model, err +} + +func (p *Plugin) DeleteAttestedNode(ctx context.Context, req *datastorev1.DeleteAttestedNodeRequest) (*datastorev1.DeleteAttestedNodeResponse, error) { + if req == nil || req.SpiffeId == "" { + return nil, status.Error(codes.InvalidArgument, "spiffe id is required") + } + + attestedNode, err := p.FetchAttestedNode(ctx, &datastorev1.FetchAttestedNodeRequest{ + SpiffeId: req.SpiffeId, + }) + if err != nil { + return nil, err + } + if attestedNode == nil || attestedNode.Node == nil { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + + query := qb.NewDelete(). + From("attested_node_entries"). + Where("spiffe_id", qb.Equals(req.SpiffeId)) + q, _ := query.Build() + + if err := p.db.session.Query(q, req.SpiffeId).Consistency(p.db.cfg.WriteConsistency).ExecContext(ctx); err != nil { + return nil, newWrappedCassandraError(err) + } + + err = p.createAttestedNodeEvent(ctx, &datastorev1.AttestedNodeEvent{ + SpiffeId: req.SpiffeId, + }) + + attestedNode.Node.Selectors = nil // we don't want to return selectors on delete, we can make this better by fetching the node without selectors in the first place + return &datastorev1.DeleteAttestedNodeResponse{ + Node: attestedNode.Node, + }, err +} + +func (p *Plugin) FetchAttestedNode(ctx context.Context, req *datastorev1.FetchAttestedNodeRequest) (*datastorev1.FetchAttestedNodeResponse, error) { + if req == nil || req.SpiffeId == "" { + return nil, status.Error(codes.InvalidArgument, "spiffe id is required") + } + + q := qb.NewSelect(). + Column("spiffe_id"). + Column("attestation_data_type"). + Column("serial_number"). + Column("cert_not_after"). + Column("new_serial_number"). + Column("new_cert_not_after"). + Column("can_reattest"). + Column("selector_type_value_full"). + From("attested_node_entries"). + Where("spiffe_id", qb.Equals(req.SpiffeId)). + Limit(1) // TODO(tjons): this can probably make data go missing + query, _ := q.Build() + + var ( + model = new(datastorev1.AttestedNode) + selectorTypeValueFull []string + ) + if err := p.db.session.Query(query, q.QueryValues()...).Consistency(p.db.cfg.ReadConsistency).ScanContext(ctx, + &model.SpiffeId, + &model.AttestationDataType, + &model.CertSerialNumber, + &model.CertNotAfter, + &model.NewCertSerialNumber, + &model.NewCertNotAfter, + &model.CanReattest, + &selectorTypeValueFull, + ); err != nil { + if errors.Is(err, gocql.ErrNotFound) { + return nil, nil + } + return nil, newWrappedCassandraError(err) + } + + model.Selectors = selectorStringsToSelectorObjs(selectorTypeValueFull) + + return &datastorev1.FetchAttestedNodeResponse{ + Node: model, + }, nil +} + +func (p *Plugin) ListAttestedNodes(ctx context.Context, req *datastorev1.ListAttestedNodesRequest) (*datastorev1.ListAttestedNodesResponse, error) { + pager := pages.NewQueryPaginator(req.GetPagination() != nil, req.GetPagination().GetPageSize(), req.GetPagination().GetPageToken()) + + if req.BySelectors != nil && len(req.BySelectors.Selectors) == 0 { + return nil, status.Error(codes.InvalidArgument, "cannot list by empty selectors set") + } + var includeExtraCol bool + + q := qb.NewSelect(). + Column("spiffe_id"). + Column("attestation_data_type"). + Column("serial_number"). + Column("cert_not_after"). + Column("new_serial_number"). + Column("new_cert_not_after"). + Column("can_reattest"). + From("attested_node_entries"). + AllowFiltering() + + if req.FetchSelectors { + q.Column("selector_type_value_full") + } + + if req.ByBanned { + if req.BannedValue { + // The original SQL implementation marks nodes as "banned" by setting + // their serial number to an empty string. However, since Cassandra + // does not support filtering with "!=" operator, we add a dedicated + // "banned" boolean column to simplify queries. + q.Where("banned", qb.Equals(true)) + } else { + q.Where("banned", qb.Equals(false)) + } + } + if req.ByAttestationType != "" { + q.Where("attestation_data_type", qb.Equals(req.ByAttestationType)) + } + if req.ByExpiresBefore > 0 { + q.Where("cert_not_after", qb.LessThan(req.ByExpiresBefore)) + } + if req.ByValidAt > 0 { + q.Where("cert_not_after", qb.GreaterThan(req.ByValidAt)) + } + if req.ByCanReattest { + q.Where("can_reattest", qb.Equals(req.CanReattestValue)) + } + if req.BySelectors != nil { + includeExtraCol = generateSelectorFilters(req.BySelectors, q) + if includeExtraCol { + q.Column("updated_at") + } + } else { + q.Distinct() // No need to fetch multiple rows per node + } + + query, _ := q.Build() + + cqlQuery := p.db.session.Query(query, q.QueryValues()...) + cqlQuery.Consistency(p.db.cfg.ReadConsistency) + + cqlQuery = pager.BindToQuery(cqlQuery) + + iter := cqlQuery.IterContext(ctx) + scanner := iter.Scanner() + pager.ForIter(iter) + + // we use a hack here that may not be a great idea, but it sure is creative! + // TODO(tjons): fix! + attestedNodes := make(map[string]*datastorev1.AttestedNode, iter.NumRows()) + for scanner.Next() { + var ( + err error + model datastorev1.AttestedNode + selectorTypeValueFull []string + + scanVals = []any{ + &model.SpiffeId, + &model.AttestationDataType, + &model.CertSerialNumber, + &model.CertNotAfter, + &model.NewCertSerialNumber, + &model.NewCertNotAfter, + &model.CanReattest, + } + ) + + // depending on the input request, we may need to scan different columns, + // so we'll handle that here by checking the request parameters and scanning + // accordingly into the correct variables. + // + // we will assign the error from scanner.Scan to err, and handle it below + // to avoid duplicating error handling code. + if req.FetchSelectors { + scanVals = append(scanVals, &selectorTypeValueFull) + } + if includeExtraCol { + // we need to include the row-level distinguishing column + // in some cases to allow filtering on non-static columns + scanVals = append(scanVals, &model.UpdatedAt) + } + + if err = scanner.Scan(scanVals...); err != nil { + return nil, newWrappedCassandraError(err) + } + + // TODO(tjons): can we avoid having to store these selectors in the intermediary type? + model.Selectors = selectorStringsToSelectorObjs(selectorTypeValueFull) + attestedNodes[model.SpiffeId] = &model + } + + if err := scanner.Err(); err != nil { + return nil, newWrappedCassandraError(err) + } + + pager.NextPageToken() + + resp := &datastorev1.ListAttestedNodesResponse{ + Nodes: slices.Collect(maps.Values(attestedNodes)), + Pagination: responsePaginationFromPager(pager), + } + + // if req.Pagination != nil { + // resp.Pagination = &datastore.Pagination{ + // PageSize: req.Pagination.PageSize, + // } + + // TODO(tjons): this is really weird, and I'm not sure that this is entirely correct to disable it, because + // tests pass without it but fail with it because we don't send a "next page" token when there are no more pages. + // Bizarrely, ListRegistrationEntries tests don't pass without it! + + // peeker := p.db.session.Query(query, q.QueryValues()...) + // peeker.Consistency(gocql.LocalQuorum) + + // peeker.PageState(pageState) + // peeker.PageSize(1) + // peekIter := peeker.IterContext(ctx) + // if peekIter.NumRows() > 0 { + // resp.Pagination.Token = base64.URLEncoding.Strict().EncodeToString(pageState) + // } + // if err := peekIter.Close(); err != nil { + // return nil, newWrappedCassandraError(err) + // } + // } + + return resp, nil +} + +var AllTrueAgentMask = &datastorev1.AttestedNodeMask{ + AttestationDataType: true, + CertSerialNumber: true, + CertNotAfter: true, + NewCertSerialNumber: true, + NewCertNotAfter: true, + CanReattest: true, + AgentVersion: true, +} + +func (p *Plugin) UpdateAttestedNode(ctx context.Context, req *datastorev1.UpdateAttestedNodeRequest) (*datastorev1.UpdateAttestedNodeResponse, error) { + if req == nil || req.Node == nil { + return nil, newCassandraError("invalid request: missing attested node") + } + + existingNodeResp, err := p.FetchAttestedNode(ctx, &datastorev1.FetchAttestedNodeRequest{ + SpiffeId: req.Node.SpiffeId, + }) + if err != nil { + return nil, err + } + if existingNodeResp.GetNode() == nil { + return nil, status.Error(codes.NotFound, NotFoundErr.Error()) + } + existingNode := existingNodeResp.GetNode() + + if req.Mask == nil { + req.Mask = AllTrueAgentMask + } + + updateQ := qb.NewUpdate(). + Table("attested_node_entries"). + Where("spiffe_id", qb.Equals(req.Node.SpiffeId)) + + var hasWrites bool + if req.Mask.CertNotAfter { + existingNode.CertNotAfter = req.Node.CertNotAfter + updateQ.Set("cert_not_after", req.Node.CertNotAfter) + hasWrites = true + } + if req.Mask.CertSerialNumber { // TODO(tjons): tighten up the field naming and column naming for clarity now that we have like 4 places for the same thing + existingNode.CertSerialNumber = req.Node.CertSerialNumber + updateQ.Set("serial_number", req.Node.CertSerialNumber) + hasWrites = true + + if req.Node.CertSerialNumber == "" { + // The original SQL implementation marks nodes as "banned" by setting + // their serial number to an empty string. However, since Cassandra + // does not support filtering with "!=" operator, we add a dedicated + // "banned" boolean column to simplify queries. + updateQ.Set("banned", true) + } else { + updateQ.Set("banned", false) + } + } + if req.Mask.NewCertNotAfter { + existingNode.NewCertNotAfter = req.Node.NewCertNotAfter + if req.Node.NewCertNotAfter != 0 { + updateQ.Set("new_cert_not_after", req.Node.NewCertNotAfter) + } else { + updateQ.Set("new_cert_not_after", nil) + } + hasWrites = true + } + if req.Mask.NewCertSerialNumber { + existingNode.NewCertSerialNumber = req.Node.NewCertSerialNumber + updateQ.Set("new_serial_number", req.Node.NewCertSerialNumber) + hasWrites = true + } + if req.Mask.CanReattest { + existingNode.CanReattest = req.Node.CanReattest + updateQ.Set("can_reattest", req.Node.CanReattest) + hasWrites = true + } + if req.Mask.AgentVersion { + existingNode.AgentVersion = req.Node.AgentVersion + updateQ.Set("agent_version", req.Node.AgentVersion) + hasWrites = true + } + + if hasWrites { + q, _ := updateQ.Build() + if err := p.db.session.Query(q, updateQ.QueryValues()...).Consistency(p.db.cfg.WriteConsistency).ExecContext(ctx); err != nil { + return nil, newWrappedCassandraError(err) + } + } + + if err = p.createAttestedNodeEvent(ctx, &datastorev1.AttestedNodeEvent{ + SpiffeId: req.Node.SpiffeId, + }); err != nil { + return nil, err + } + + return &datastorev1.UpdateAttestedNodeResponse{ + Node: existingNode, + }, nil +} + +func (p *Plugin) PruneAttestedExpiredNodes( + ctx context.Context, + req *datastorev1.PruneAttestedExpiredNodesRequest, +) (*datastorev1.PruneAttestedExpiredNodesResponse, error) { + if req == nil || req.ExpiresBefore == 0 { + return nil, newCassandraError("invalid request: missing expired_before timestamp") + } + + findQ := qb.NewSelect(). + Distinct(). + Column("spiffe_id"). + Column("serial_number"). + From("attested_node_entries"). + Where("cert_not_after", qb.LessThan(req.GetExpiresBefore())). + Where("can_reattest", qb.Equals(!req.GetIncludeNonReattestable())). + AllowFiltering() + + query, _ := findQ.Build() + iter := p.db.session.Query(query, findQ.QueryValues()...).Consistency(p.db.cfg.ReadConsistency).IterContext(ctx) + scanner := iter.Scanner() + + var spiffeIDs []string + for scanner.Next() { + var spiffeID, serialNumber string + if err := scanner.Scan(&spiffeID, &serialNumber); err != nil { + return nil, newWrappedCassandraError(err) + } + + // since Cassandra doesn't support `!=` in SELECT statments, we filter the + // banned entries here. SPIRE marks nodes as banned by setting their + // serial number to an empty string. + if len(serialNumber) == 0 { + continue + } + spiffeIDs = append(spiffeIDs, spiffeID) + } + if err := scanner.Err(); err != nil { + return nil, newWrappedCassandraError(err) + } + + for _, spiffeID := range spiffeIDs { + query := qb.NewDelete(). + From("attested_node_entries"). + Where("spiffe_id", qb.Equals(spiffeID)) + q, _ := query.Build() + if err := p.db.session.Query(q, spiffeID).Consistency(p.db.cfg.WriteConsistency).ExecContext(ctx); err != nil { + return nil, newWrappedCassandraError(err) + } + + if err := p.createAttestedNodeEvent(ctx, &datastorev1.AttestedNodeEvent{ + SpiffeId: spiffeID, + }); err != nil { + return nil, err + } + } + + return &datastorev1.PruneAttestedExpiredNodesResponse{}, nil +} + +func (p *Plugin) GetNodeSelectors(ctx context.Context, req *datastorev1.GetNodeSelectorsRequest) (*datastorev1.GetNodeSelectorsResponse, error) { + if req == nil || req.SpiffeId == "" { + return nil, status.Error(codes.InvalidArgument, "spiffe id is required") + } + + q := qb.NewSelect(). + Column("selector_type_value_full"). + From("attested_node_entries"). + Where("spiffe_id", qb.Equals(req.SpiffeId)). + Limit(1) + query, _ := q.Build() + + var selectorTypeValueFull []string + if err := p.db.session.Query(query, req.SpiffeId).Consistency(p.db.cfg.ReadConsistency).ScanContext(ctx, &selectorTypeValueFull); err != nil { + if !errors.Is(err, gocql.ErrNotFound) { + return nil, newWrappedCassandraError(err) + } + + return nil, nil + } + + return &datastorev1.GetNodeSelectorsResponse{ + Selectors: selectorStringsToSelectorObjs(selectorTypeValueFull), + }, nil +} + +func (p *Plugin) ListNodeSelectors(ctx context.Context, req *datastorev1.ListNodeSelectorsRequest) (*datastorev1.ListNodeSelectorsResponse, error) { + if req == nil { + return nil, status.Error(codes.InvalidArgument, "request is required") + } + + q := qb.NewSelect(). + Distinct(). + Column("spiffe_id"). + Column("selector_type_value_full"). + From("attested_node_entries"). + AllowFiltering() + + if req.ValidAt != 0 { + q.Where("cert_not_after", qb.GreaterThan(req.ValidAt)) + } + + query, _ := q.Build() + + iter := p.db.session.Query(query, q.QueryValues()...).Consistency(p.db.cfg.ReadConsistency).IterContext(ctx) + scanner := iter.Scanner() + selectorEntries := make(map[string]*datastorev1.NodeSelectorEntry, iter.NumRows()) + + for scanner.Next() { + var ( + spiffeID string + stvList []string + ) + if err := scanner.Scan(&spiffeID, &stvList); err != nil { + return nil, newWrappedCassandraError(err) + } + + selectorEntries[spiffeID] = &datastorev1.NodeSelectorEntry{ + SpiffeId: spiffeID, + Selectors: selectorStringsToSelectorObjs(stvList), + } + } + + if err := scanner.Err(); err != nil { + return nil, newWrappedCassandraError(err) + } + + return &datastorev1.ListNodeSelectorsResponse{ + Selectors: slices.Collect(maps.Values(selectorEntries)), + }, nil +} + +func (p *Plugin) SetNodeSelectors(ctx context.Context, req *datastorev1.SetNodeSelectorsRequest) (*datastorev1.SetNodeSelectorsResponse, error) { + if req == nil || req.SpiffeId == "" { + return nil, status.Error(codes.InvalidArgument, "spiffe id is required") + } + + nodeResp, err := p.FetchAttestedNode(ctx, &datastorev1.FetchAttestedNodeRequest{ + SpiffeId: req.SpiffeId, + }) + if err != nil { + return nil, err + } + node := nodeResp.GetNode() + + var existingSelectors []*datastorev1.Selector + if node != nil { + existingSelectors = node.Selectors + } + + if err = p.setNodeSelectors(ctx, req.SpiffeId, req.Selectors, existingSelectors); err != nil { + return nil, err + } + + return &datastorev1.SetNodeSelectorsResponse{}, p.createAttestedNodeEvent(ctx, &datastorev1.AttestedNodeEvent{ + SpiffeId: req.SpiffeId, + }) +} + +func (p *Plugin) setNodeSelectors(ctx context.Context, spiffeID string, newSelectors, existingSelectors []*datastorev1.Selector) error { + selectorsToDelete := make(map[string]struct{}, len(existingSelectors)) + selectorsToInsert := make(map[string]struct{}, len(newSelectors)) + for _, sel := range existingSelectors { + key := selectorToString(sel) + selectorsToDelete[key] = struct{}{} + } + + for _, sel := range newSelectors { + key := selectorToString(sel) + delete(selectorsToDelete, key) + selectorsToInsert[key] = struct{}{} + } + + if len(selectorsToDelete) == 0 && len(selectorsToInsert) == 0 { + return nil + } + + b := p.db.session.Batch(gocql.LoggedBatch) + b.Consistency(p.db.cfg.WriteConsistency) + + for sel := range selectorsToDelete { + deleteQuery := qb.NewDelete(). + From("attested_node_entries"). + Where("spiffe_id", qb.Equals(spiffeID)). + Where("selector_type_value", qb.Equals(sel)) + deleteCQL, _ := deleteQuery.Build() + + b.Query(deleteCQL, deleteQuery.QueryValues()...) + } + + newStvFull := make([]string, 0) + for sel := range selectorsToInsert { + newStvFull = append(newStvFull, sel) + } + + for i := range newStvFull { + setStvQuery := qb.NewInsert(). + Into("attested_node_entries"). + Columns( + "spiffe_id", + "selector_type_value", + "selector_type_value_full", + "index_terms", + ). + Values( + spiffeID, + newStvFull[i], + newStvFull, + buildSelectorIndexes(newSelectors), + ) + q, _ := setStvQuery.Build() + + b.Query(q, setStvQuery.QueryValues()...) + } + + if len(newStvFull) == 0 { + // If there are no selectors left, we still need to update the + // selector_type_value_full to an empty list + deletePartitionList := qb.NewDelete(). + Column("selector_type_value_full"). + Column("index_terms"). + From("attested_node_entries"). + Where("spiffe_id", qb.Equals(spiffeID)) + q, _ := deletePartitionList.Build() + + b.Query(q, deletePartitionList.QueryValues()...) + } + + if err := b.ExecContext(ctx); err != nil { + return newWrappedCassandraError(err) + } + + return nil +} diff --git a/pkg/server/datastore/cassandra/nodes_events.go b/pkg/server/datastore/cassandra/nodes_events.go new file mode 100644 index 0000000000..4ca91b00f0 --- /dev/null +++ b/pkg/server/datastore/cassandra/nodes_events.go @@ -0,0 +1,214 @@ +package cassandra + +import ( + "context" + "errors" + "slices" + "strings" + "time" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" +) + +func (p *Plugin) ListAttestedNodeEvents( + ctx context.Context, + req *datastorev1.ListAttestedNodeEventsRequest, +) (*datastorev1.ListAttestedNodeEventsResponse, error) { + if req == nil { + return nil, errors.New("request is required") + } + + b := strings.Builder{} + b.WriteString("SELECT event_id, spiffe_id, created_at FROM attested_node_entries_events ") + var args []any + + switch { + case req.GetGreaterThanEventId() > 0 && req.GetLessThanEventId() > 0: + return nil, errors.New("can't set both greater and less than event id") + case req.GetLessThanEventId() > 0: + b.WriteString("WHERE event_id < ? ") + args = append(args, req.GetLessThanEventId()) + case req.GetGreaterThanEventId() > 0: + b.WriteString("WHERE event_id > ? ") + args = append(args, req.GetGreaterThanEventId()) + } + b.WriteString(" ALLOW FILTERING") + + iter := p.db.session.Query(b.String(), args...).Consistency(p.db.cfg.ReadConsistency).IterContext(ctx) + scanner := iter.Scanner() + events := make([]*datastorev1.AttestedNodeEvent, 0) + + for scanner.Next() { + var ( + eventID uint + spiffeID string + createdAt time.Time + ) + + if err := scanner.Scan( + &eventID, + &spiffeID, + &createdAt, + ); err != nil { + return nil, err + } + + events = append(events, &datastorev1.AttestedNodeEvent{ + EventId: uint64(eventID), + SpiffeId: spiffeID, + CreatedAt: createdAt.Unix(), + }) + } + if err := scanner.Err(); err != nil { + return nil, err + } + + slices.SortStableFunc(events, func(a, b *datastorev1.AttestedNodeEvent) int { + if a.EventId < b.EventId { + return -1 + } + if a.EventId > b.EventId { + return 1 + } + return 0 + }) + + dsEvents := make([]*datastorev1.AttestedNodeEvent, len(events)) + for i := range events { + dsEvents[i] = &datastorev1.AttestedNodeEvent{ + EventId: uint64(events[i].EventId), + SpiffeId: events[i].SpiffeId, + } + } + + return &datastorev1.ListAttestedNodeEventsResponse{ + Events: dsEvents, + }, nil +} + +func (p *Plugin) createAttestedNodeEvent(ctx context.Context, event *datastorev1.AttestedNodeEvent) error { + if event.EventId == 0 { + nextID, err := p.getNextAttestedNodeEventID(ctx) + if err != nil { + return err + } + event.EventId = nextID + } + + query := `INSERT INTO attested_node_entries_events (event_id, created_at, updated_at, spiffe_id) VALUES (?, ?, ?, ?)` + if err := p.db.session.Query(query, + event.EventId, + time.Now().UTC(), + time.Now().UTC(), + event.SpiffeId, + ).Consistency(p.db.cfg.WriteConsistency).ExecContext(ctx); err != nil { + return newCassandraError("failed to create attested node event: %s", err.Error()) + } + + return nil +} + +func (p *Plugin) getNextAttestedNodeEventID(ctx context.Context) (uint64, error) { + q := `SELECT max(event_id) FROM attested_node_entries_events ALLOW FILTERING` + + var maxID *uint64 + if err := p.db.session.Query(q).Consistency(p.db.cfg.ReadConsistency).ScanContext(ctx, &maxID); err != nil && err != gocql.ErrNotFound { + return 0, newCassandraError("failed to get max attested node event ID: %s", err.Error()) + } + if maxID == nil { + return 1, nil + } + + return uint64(*maxID) + 1, nil +} + +func (p *Plugin) PruneAttestedNodeEvents(ctx context.Context, req *datastorev1.PruneAttestedNodeEventsRequest) (*datastorev1.PruneAttestedNodeEventsResponse, error) { + cutoff := time.Now().UTC().Add(-time.Duration(req.OlderThan * int64(time.Second))) + + idsQ := `SELECT event_id, spiffe_id FROM attested_node_entries_events WHERE created_at < ? ALLOW FILTERING` + scanner := p.db.session.Query(idsQ, cutoff).Consistency(p.db.cfg.ReadConsistency).IterContext(ctx).Scanner() + + var events []*datastorev1.AttestedNodeEvent + for scanner.Next() { + event := new(datastorev1.AttestedNodeEvent) + if err := scanner.Scan(&event.EventId, &event.SpiffeId); err != nil { + return nil, err + } + events = append(events, event) + } + if err := scanner.Err(); err != nil { + return nil, err + } + + b := p.db.session.Batch(gocql.LoggedBatch).Consistency(p.db.cfg.WriteConsistency) + deleteQ := `DELETE FROM attested_node_entries_events WHERE spiffe_id = ? AND event_id = ?` + for _, event := range events { + b.Entries = append(b.Entries, gocql.BatchEntry{ + Stmt: deleteQ, + Args: []any{event.SpiffeId, event.EventId}, + Idempotent: true, + }) + } + if err := b.ExecContext(ctx); err != nil { + return nil, err + } + + return &datastorev1.PruneAttestedNodeEventsResponse{}, nil +} + +func (p *Plugin) FetchAttestedNodeEvent(ctx context.Context, req *datastorev1.FetchAttestedNodeEventRequest) (*datastorev1.FetchAttestedNodeEventResponse, error) { + q := `SELECT event_id, spiffe_id FROM attested_node_entries_events WHERE event_id = ?` + + var event datastorev1.AttestedNodeEvent + if err := p.db.session.Query(q, req.EventId).Consistency(p.db.cfg.ReadConsistency).ScanContext(ctx, + &event.EventId, + &event.SpiffeId, + ); err != nil { + if err == gocql.ErrNotFound { + return nil, NotFoundErr + } + return nil, newCassandraError("failed to fetch attested node event: %s", err.Error()) + } + + return &datastorev1.FetchAttestedNodeEventResponse{ + Event: &event, + }, nil +} + +func (p *Plugin) CreateAttestedNodeEvent(ctx context.Context, req *datastorev1.CreateAttestedNodeEventRequest) (*datastorev1.CreateAttestedNodeEventResponse, error) { + err := p.createAttestedNodeEvent(ctx, &datastorev1.AttestedNodeEvent{ + EventId: req.Event.EventId, + SpiffeId: req.Event.SpiffeId, + }) + if err != nil { + return nil, err + } + + return &datastorev1.CreateAttestedNodeEventResponse{}, nil +} + +func (p *Plugin) DeleteAttestedNodeEvent(ctx context.Context, req *datastorev1.DeleteAttestedNodeEventRequest) (*datastorev1.DeleteAttestedNodeEventResponse, error) { + findEventQ := `SELECT spiffe_id FROM attested_node_entries_events WHERE event_id = ?` + + var spiffeID string + if err := p.db.session.Query(findEventQ, req.EventId). + Consistency(p.db.cfg.ReadConsistency). + ScanContext(ctx, &spiffeID); err != nil { + if err == gocql.ErrNotFound { + return nil, NotFoundErr + } + return nil, newCassandraError("failed to find attested node event for deletion: %s", err.Error()) + } + + deleteQ := `DELETE FROM attested_node_entries_events WHERE spiffe_id = ? AND event_id = ?` + if err := p.db.session.Query( + deleteQ, + spiffeID, + req.EventId, + ).Consistency(p.db.cfg.WriteConsistency).ExecContext(ctx); err != nil { + return nil, newCassandraError("failed to delete attested node event: %s", err.Error()) + } + + return &datastorev1.DeleteAttestedNodeEventResponse{}, nil +} diff --git a/pkg/server/datastore/cassandra/pager.go b/pkg/server/datastore/cassandra/pager.go new file mode 100644 index 0000000000..853ca194a4 --- /dev/null +++ b/pkg/server/datastore/cassandra/pager.go @@ -0,0 +1,17 @@ +package cassandra + +import ( + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + "github.com/tjons/cassandra-toolbox/qb/pages" +) + +func responsePaginationFromPager(pager *pages.QueryPaginator) *datastorev1.Pagination { + if pager == nil { + return nil + } + + return &datastorev1.Pagination{ + PageSize: pager.PageSize, + PageToken: pager.NextPageToken(), + } +} diff --git a/pkg/server/datastore/cassandra/plugin.go b/pkg/server/datastore/cassandra/plugin.go new file mode 100644 index 0000000000..450cf6db10 --- /dev/null +++ b/pkg/server/datastore/cassandra/plugin.go @@ -0,0 +1,106 @@ +package cassandra + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/hcl" + "github.com/sirupsen/logrus" + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" +) + +// PluginName is the name of the Cassandra datastore plugin. +const PluginName = "cassandra" + +// Plugin implements the datastore plugin interface for Cassandra. +type Plugin struct { + datastorev1.UnsafeDataStoreServer + configv1.UnsafeConfigServer + + rlock *sync.Mutex + rwLock *sync.Mutex + log logrus.FieldLogger + cfg *Configuration + db *cassandraDB +} + +// New creates a new instance of the Cassandra datastore plugin with the provided logger. +func New(log logrus.FieldLogger) Plugin { + return Plugin{ + rlock: &sync.Mutex{}, + rwLock: &sync.Mutex{}, + log: log, + } +} + +// NewPlugin creates a new instance of the Cassandra datastore plugin with a default logger. +func NewPlugin() *Plugin { + return &Plugin{ + rlock: &sync.Mutex{}, + rwLock: &sync.Mutex{}, + log: logrus.New().WithField("component", "datastore-cassandra"), // TODO(tjons): this is weird? + } +} + +// TODO(tjons): figure out what to do with this +func (p *Plugin) SetLogger(log hclog.Logger) { + // p.log = log +} + +// Name returns the name of the plugin, which is "cassandra". +func (p *Plugin) Name() string { + return PluginName +} + +func (p *Plugin) Validate(ctx context.Context, req *configv1.ValidateRequest) (*configv1.ValidateResponse, error) { + return &configv1.ValidateResponse{}, nil // TODO(tjons): IMPLEMENT!! +} + +// Close terminates the plugin and releases any resources, including closing the connection to the Cassandra database. +func (p *Plugin) Close() error { + p.db.session.Close() + p.log.Infof("Closing connection to cassandra...") + return nil +} + +// Configure initializes the plugin with the provided configuration, including establishing a connection to the Cassandra database. +func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) { + cfg := &Configuration{} + if err := hcl.Decode(cfg, req.HclConfiguration); err != nil { + return nil, err + } + + p.cfg = cfg + + runtimeCfg := &runtimeConfiguration{} + if err := runtimeCfg.FromUserConfig(cfg); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + var ( + successful bool + err error + ) + for range runtimeCfg.MaxConnectionAttempts { + // open connections to cassandra and initialize the session + err = p.openConnections(ctx, runtimeCfg) + if err != nil { + p.log.Errorf("Error attempting to initialize connection to Cassandra: %s", err.Error()) + time.Sleep(initialConnectionBackoff) + continue + } + + successful = true + break + } + + if !successful { + return nil, err + } + + return &configv1.ConfigureResponse{}, nil +} diff --git a/pkg/server/datastore/cassandra/selectors.go b/pkg/server/datastore/cassandra/selectors.go new file mode 100644 index 0000000000..263075eb3f --- /dev/null +++ b/pkg/server/datastore/cassandra/selectors.go @@ -0,0 +1,44 @@ +package cassandra + +import ( + "slices" + "strings" + + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" +) + +func selectorToString(s *datastorev1.Selector) string { + return s.Type + "|" + s.Value +} + +func selectorStringsToSelectorObjs(selectorStrings []string) []*datastorev1.Selector { + selectors := make([]*datastorev1.Selector, len(selectorStrings)) + + for i, s := range selectorStrings { + sel := stringToSelector(s) + if sel != nil { + selectors[i] = sel + } + } + + slices.SortFunc(selectors, func(a, b *datastorev1.Selector) int { + if typeCompare := strings.Compare(a.Type, b.Type); typeCompare != 0 { + return typeCompare + } + + return strings.Compare(a.Value, b.Value) + }) + + return selectors +} + +func stringToSelector(s string) *datastorev1.Selector { + parts := strings.SplitN(s, "|", 2) + if len(parts) != 2 { + return nil + } + return &datastorev1.Selector{ + Type: parts[0], + Value: parts[1], + } +} diff --git a/pkg/server/datastore/cassandra/tokens.go b/pkg/server/datastore/cassandra/tokens.go new file mode 100644 index 0000000000..81f9585cc0 --- /dev/null +++ b/pkg/server/datastore/cassandra/tokens.go @@ -0,0 +1,118 @@ +package cassandra + +import ( + "context" + "errors" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + "github.com/tjons/cassandra-toolbox/qb" +) + +func (p *Plugin) CreateJoinToken( + ctx context.Context, + req *datastorev1.CreateJoinTokenRequest, +) (*datastorev1.CreateJoinTokenResponse, error) { + if req == nil || req.Token == "" || req.ExpiresAt == 0 { + return nil, errors.New("token and expiry are required") + } + + jt, err := p.FetchJoinToken(ctx, &datastorev1.FetchJoinTokenRequest{Token: req.Token}) + if err != nil { + return nil, err + } + if jt != nil { + return nil, newWrappedCassandraError(errors.New("join token already exists")) + } + + // TODO (tjons): this query could really really benefit from an LWT + createQuery := qb.NewInsert(). + Into("join_tokens"). + Columns("join_token", "expiry"). + Values(req.Token, req.ExpiresAt) + if err = p.db.WriteQuery(createQuery).ExecContext(ctx); err != nil { + return nil, err + } + + return &datastorev1.CreateJoinTokenResponse{}, nil +} + +func (p *Plugin) DeleteJoinToken( + ctx context.Context, + req *datastorev1.DeleteJoinTokenRequest, +) (*datastorev1.DeleteJoinTokenResponse, error) { + jt, err := p.FetchJoinToken(ctx, &datastorev1.FetchJoinTokenRequest{Token: req.Token}) + if err != nil { + return nil, err + } + if jt == nil { + return nil, newWrappedCassandraError(errors.New("join token not found")) + } + + // TODO (tjons): this could really really benefit from an LWT + deleteQuery := qb.NewDelete().From("join_tokens").Where("join_token", qb.Equals(req.Token)) + if err = p.db.WriteQuery(deleteQuery).ExecContext(ctx); err != nil { + return nil, err + } + + return &datastorev1.DeleteJoinTokenResponse{}, nil +} + +func (p *Plugin) FetchJoinToken( + ctx context.Context, + req *datastorev1.FetchJoinTokenRequest, +) (*datastorev1.FetchJoinTokenResponse, error) { + var ( + jt string + exp int64 + ) + + findQuery := qb.NewSelect(). + From("join_tokens"). + Where("join_token", qb.Equals(req.Token)) + + if err := p.db.ReadQuery(findQuery).ScanContext(ctx, &jt, &exp); err != nil { + if errors.Is(err, gocql.ErrNotFound) { + return nil, nil + } + return nil, err + } + + return &datastorev1.FetchJoinTokenResponse{ + Token: jt, + ExpiresAt: exp, + }, nil +} + +func (p *Plugin) PruneJoinTokens( + ctx context.Context, + req *datastorev1.PruneJoinTokensRequest, +) (*datastorev1.PruneJoinTokensResponse, error) { + findExpiredQuery := qb.NewSelect(). + From("join_tokens"). + Column("join_token"). + Where("expiry", qb.LessThan(req.ExpiresBefore)). + AllowFiltering() + + scanner := p.db.ReadQuery(findExpiredQuery).IterContext(ctx).Scanner() + expiredTokens := make([]any, 0) + for scanner.Next() { + var token string + if err := scanner.Scan(&token); err != nil { + return nil, err + } + expiredTokens = append(expiredTokens, token) + } + if err := scanner.Err(); err != nil { + return nil, err + } + + if len(expiredTokens) > 0 { + q := qb.NewDelete().From("join_tokens").Where("join_token", qb.In(expiredTokens...)) + if err := p.db.WriteQuery(q).ExecContext(ctx); err != nil { + return nil, err + } + } + + return &datastorev1.PruneJoinTokensResponse{}, nil +} diff --git a/pkg/server/datastore/datastore.go b/pkg/server/datastore/datastore.go index 63884bdb61..536b57be58 100644 --- a/pkg/server/datastore/datastore.go +++ b/pkg/server/datastore/datastore.go @@ -7,6 +7,7 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" "github.com/spiffe/spire/proto/spire/common" ) @@ -33,7 +34,7 @@ type DataStore interface { CountRegistrationEntries(context.Context, *CountRegistrationEntriesRequest) (int32, error) CreateRegistrationEntry(context.Context, *common.RegistrationEntry) (*common.RegistrationEntry, error) CreateOrReturnRegistrationEntry(context.Context, *common.RegistrationEntry) (*common.RegistrationEntry, bool, error) - DeleteRegistrationEntry(ctx context.Context, entryID string) (*common.RegistrationEntry, error) + DeleteRegistrationEntry(ctx context.Context, entryID string) (*common.RegistrationEntry, error) // TODO(tjons): it would be really nice if we didn't have to return from delete FetchRegistrationEntry(ctx context.Context, entryID string) (*common.RegistrationEntry, error) FetchRegistrationEntries(ctx context.Context, entryIDs []string) (map[string]*common.RegistrationEntry, error) ListRegistrationEntries(context.Context, *ListRegistrationEntriesRequest) (*ListRegistrationEntriesResponse, error) @@ -86,6 +87,20 @@ type DataStore interface { FetchCAJournal(ctx context.Context, activeX509AuthorityID string) (*CAJournal, error) PruneCAJournals(ctx context.Context, allCAsExpireBefore int64) error ListCAJournalsForTesting(ctx context.Context) ([]*CAJournal, error) + + // Methods for use when loading the datastore as a plugin via the plugin catalog. + Close() error + Name() string + Type() string +} + +// ConfigurableDatastore is an optional interface that a datastore plugin can implement +// to allow dynamic configuration and validation of that configuration. +type ConfigurableDataStore interface { + DataStore + + Configure(context.Context, *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) + Validate(context.Context, *configv1.ValidateRequest) (*configv1.ValidateResponse, error) } // DataConsistency indicates the required data consistency for a read operation. diff --git a/pkg/server/datastore/datastore_test.go b/pkg/server/datastore/datastore_test.go new file mode 100644 index 0000000000..396f74abf2 --- /dev/null +++ b/pkg/server/datastore/datastore_test.go @@ -0,0 +1,6209 @@ +package datastore_test + +import ( + "context" + "crypto/x509" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/url" + "path/filepath" + "reflect" + "slices" + "sort" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/common/bundleutil" + "github.com/spiffe/spire/pkg/common/catalog" + "github.com/spiffe/spire/pkg/common/protoutil" + "github.com/spiffe/spire/pkg/common/util" + "github.com/spiffe/spire/pkg/common/x509util" + "github.com/spiffe/spire/pkg/server/datastore" + "github.com/spiffe/spire/pkg/server/datastore/sqlstore" + "github.com/spiffe/spire/pkg/server/datastore/testdata" + ds_plugin "github.com/spiffe/spire/pkg/server/plugin/datastore" + cassandra_plugin "github.com/spiffe/spire/pkg/server/plugin/datastore/cassandra" + "github.com/spiffe/spire/proto/private/server/journal" + "github.com/spiffe/spire/proto/spire/common" + "github.com/spiffe/spire/test/clock" + "github.com/spiffe/spire/test/plugintest" + "github.com/spiffe/spire/test/spiretest" + "github.com/spiffe/spire/test/testkey" + testutil "github.com/spiffe/spire/test/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +var ( + ctx = context.Background() + + // The following are set by the linker during integration tests to + // run these unit tests against various SQL backends. + TestDialect string + TestConnString string + TestROConnString string +) + +const ( + _ttl = time.Hour + _expiredNotAfterString = "2018-01-10T01:34:00+00:00" + _validNotAfterString = "2018-01-10T01:36:00+00:00" + _middleTimeString = "2018-01-10T01:35:00+00:00" + datastoreSQLNotFoundErrorMessage = "datastore-sql: record not found" + datastoreCassandraNotFoundErrorMessage = "datastore(cassandra): record not found" +) + +var _notFoundErrMsg = func() string { + if TestDialect == "cassandra" { + return datastoreCassandraNotFoundErrorMessage + } + + return datastoreSQLNotFoundErrorMessage +}() + +func wrapErrMsg(msg string) string { + // The plugin framework will enrich errors returned by plugins with additional + // context, so we need to be able to wrap the error when using a plugin-based + // datastore. + if TestDialect == "cassandra" && !strings.HasPrefix(msg, "datastore(cassandra):") && len(msg) > 0 { + return fmt.Sprintf("datastore(cassandra): %s", msg) + } + return msg +} + +func TestPlugin(t *testing.T) { + spiretest.Run(t, new(PluginSuite)) +} + +type PluginSuite struct { + spiretest.Suite + + cert *x509.Certificate + cacert *x509.Certificate + + dir string + nextID int + ds datastore.DataStore + configurableDs datastore.ConfigurableDataStore + hook *test.Hook + dsCloser func() error +} + +func (s *PluginSuite) SetupSuite() { + clk := clock.NewMock(s.T()) + + expiredNotAfterTime, err := time.Parse(time.RFC3339, _expiredNotAfterString) + s.Require().NoError(err) + validNotAfterTime, err := time.Parse(time.RFC3339, _validNotAfterString) + s.Require().NoError(err) + + caTemplate, err := testutil.NewCATemplate(clk, spiffeid.RequireTrustDomainFromString("foo")) + s.Require().NoError(err) + + caTemplate.NotAfter = expiredNotAfterTime + caTemplate.NotBefore = expiredNotAfterTime.Add(-_ttl) + + cacert, cakey, err := testutil.SelfSign(caTemplate) + s.Require().NoError(err) + + svidTemplate, err := testutil.NewSVIDTemplate(clk, "spiffe://foo/id1") + s.Require().NoError(err) + + svidTemplate.NotAfter = validNotAfterTime + svidTemplate.NotBefore = validNotAfterTime.Add(-_ttl) + + cert, _, err := testutil.Sign(svidTemplate, cacert, cakey) + s.Require().NoError(err) + + s.cacert = cacert + s.cert = cert +} + +func (s *PluginSuite) SetupTest() { + s.dir = s.TempDir() + s.ds = s.newPlugin() +} + +func (s *PluginSuite) TearDownTest() { + if s.ds != nil { + s.ds.Close() + } + // if s.dsCloser != nil { + // s.dsCloser() + // } +} + +func (s *PluginSuite) loadCassandraAsBuiltin(t *testing.T, log *logrus.Logger) datastore.DataStore { + v1 := new(ds_plugin.V1Alpha1) + + parts := strings.Split(TestConnString, ";") + s.Require().Len(parts, 2, "addresses and keyspace must both be provided for cassandra tests") + keyspace := parts[1] + var addresses []string + err := json.Unmarshal([]byte(parts[0]), &addresses) + s.Require().NoError(err, "addresses should be a valid json string containing an array of strings") + + datastoreConfig := fmt.Sprintf(` + hosts = ["%s"] + keyspace = "%s" + num_conns = 2 + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + driver_log_level = "ERROR" + write_consistency = "QUORUM" + read_consistency = "QUORUM" + `, strings.Join(addresses, `", "`), keyspace) + + p := plugintest.Load(s.T(), cassandra_plugin.BuiltIn(), v1, + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + // This should be sufficent for tests but we may want to change it in the future + plugintest.MaxGrpcMessageSize(1_000_000_000), + plugintest.Configure(datastoreConfig), + // plugintest.Log(log), // TODO(tjons): this doesn't actually work + ) + + wipeCassandra(t, addresses, keyspace) // This is fine here as long as we are using the DROP KEYSPACE approach + s.dsCloser = func() error { + err := p.Close() + if err != nil { + log.Errorf("Error closing datastore plugin: %s", err.Error()) + } + + return nil + } + + return v1 +} + +func (s *PluginSuite) newPlugin() datastore.DataStore { + log, hook := test.NewNullLogger() + var ds datastore.DataStore + s.hook = hook + + // When the test suite is executed normally, we test against sqlite3 since + // it requires no external dependencies. The integration test framework + // builds the test harness for a specific dialect and connection string + switch TestDialect { + case "": + sqlLiteStore := sqlstore.New(log) + s.nextID++ + dbPath := filepath.ToSlash(filepath.Join(s.dir, fmt.Sprintf("db%d.sqlite3", s.nextID))) + _, err := sqlLiteStore.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: fmt.Sprintf(` + database_type = "sqlite3" + log_sql = true + connection_string = "%s" + `, dbPath), + }) + s.Require().NoError(err) + + // assert that WAL journal mode is enabled + jm := struct { + JournalMode string + }{} + rawDb := sqlLiteStore.GetUnderlyingDBForTesting() + rawDb.Raw("PRAGMA journal_mode").Scan(&jm) + s.Require().Equal(jm.JournalMode, "wal") + + // assert that foreign_key support is enabled + fk := struct { + ForeignKeys string + }{} + rawDb.Raw("PRAGMA foreign_keys").Scan(&fk) + s.Require().Equal(fk.ForeignKeys, "1") + + s.configurableDs = sqlLiteStore + ds = sqlLiteStore + case "mysql": + mysqlStore := sqlstore.New(log) + + s.T().Logf("CONN STRING: %q", TestConnString) + s.Require().NotEmpty(TestConnString, "connection string must be set") + wipeMySQL(s.T(), TestConnString) + + _, err := mysqlStore.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: fmt.Sprintf(` + database_type = "mysql" + log_sql = true + connection_string = "%s" + ro_connection_string = "%s" + `, TestConnString, TestROConnString), + }) + s.Require().NoError(err) + + s.configurableDs = mysqlStore + ds = mysqlStore + case "postgres": + postgresStore := sqlstore.New(log) + + s.T().Logf("CONN STRING: %q", TestConnString) + s.Require().NotEmpty(TestConnString, "connection string must be set") + wipePostgres(s.T(), TestConnString) + _, err := postgresStore.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: fmt.Sprintf(` + database_type = "postgres" + log_sql = true + connection_string = "%s" + ro_connection_string = "%s" + `, TestConnString, TestROConnString)}) + s.Require().NoError(err) + + s.configurableDs = postgresStore + ds = postgresStore + case "cassandra": + s.T().Logf("CONN STRING: %q", TestConnString) + ds = s.loadCassandraAsBuiltin(s.T(), log) + default: + s.Require().FailNowf("Unsupported external test dialect %q", TestDialect) + } + + return ds +} + +func wipeCassandra(t *testing.T, addresses []string, keyspace string) { + cluster := gocql.NewCluster(addresses...) + cluster.NumConns = 2 + cluster.ConnectTimeout = 10 * time.Second + cluster.WriteTimeout = 11 * time.Second + cluster.Timeout = 10 * time.Second + cluster.Consistency = gocql.All + var errCount int + +sess: + sess, err := cluster.CreateSession() + if err != nil { + errCount++ + if errCount > 5 { + t.Fatalf("could not create cassandra session for wiping: %v", err) + } + time.Sleep(2 * time.Second) + goto sess + } + + // This approach of dropping the keyspace is easier than cleaning up the tables + // iteratively, but due to resource issues with connection pooling in the test suite, + // it's safer to truncate the tables one by one for now. + /* + dropKeyspaceCQL := fmt.Sprintf("DROP KEYSPACE IF EXISTS %s", keyspace) + if err := sess.Query(dropKeyspaceCQL).Exec(); err != nil { + if !strings.Contains(err.Error(), "does not exist") { + t.Fatalf("could not drop cassandra keyspace %q: %v", keyspace, err) + } + } + */ + + tables := []string{ + "registered_entries", + "registration_entry_events", + "attested_node_entries", + "attested_node_entries_events", + "bundles", + "ca_journals", + "federated_trust_domains", + "join_tokens", + } + + for _, table := range tables { + for attempt := 1; attempt <= 5; attempt++ { + truncateCQL := fmt.Sprintf("TRUNCATE %s.%s", keyspace, table) + if err := sess.Query(truncateCQL).Consistency(gocql.All).Exec(); err != nil { + t.Fatalf("could not truncate cassandra table %q: %v", table, err) + } + + var count int + countCQL := fmt.Sprintf("SELECT COUNT(*) FROM %s.%s", keyspace, table) + if err := sess.Query(countCQL).Consistency(gocql.All).Scan(&count); err != nil { + t.Logf("attempt %d: could not verify truncation of table %q: %v", attempt, table, err) + continue + } + if count != 0 { + t.Logf("attempt %d: table %q is not empty after truncation, count is %d", attempt, table, count) + time.Sleep(1 * time.Second) + continue + } + + break + } + } + + sess.Close() +} + +func (s *PluginSuite) TestInvalidPluginConfiguration() { + if s.configurableDs == nil { + s.T().Skip("plugin configuration tests only apply to configurable plugins") + } + + _, err := s.configurableDs.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: ` + database_type = "wrong" + connection_string = "bad" + `, + }) + s.RequireErrorContains(err, "datastore-sql: unsupported database_type: wrong") +} + +func (s *PluginSuite) TestInvalidAWSConfiguration() { + if s.configurableDs == nil { + s.T().Skip("plugin configuration tests only apply to configurable plugins") + } + + testCases := []struct { + name string + config string + expectedErr string + }{ + { + name: "aws_mysql - no region", + config: ` + database_type "aws_mysql" {} + connection_string = "test_user:@tcp(localhost:1234)/spire?parseTime=true&allowCleartextPasswords=1&tls=true"`, + expectedErr: "datastore-sql: region must be specified", + }, + { + name: "postgres_mysql - no region", + config: ` + database_type "aws_postgres" {} + connection_string = "dbname=postgres user=postgres host=the-host sslmode=require"`, + expectedErr: "region must be specified", + }, + } + for _, testCase := range testCases { + s.T().Run(testCase.name, func(t *testing.T) { + _, err := s.configurableDs.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: testCase.config, + }) + s.RequireErrorContains(err, testCase.expectedErr) + }) + } +} + +func (s *PluginSuite) TestInvalidMySQLConfiguration() { + if s.configurableDs == nil { + s.T().Skip("plugin configuration tests only apply to configurable plugins") + } + + _, err := s.configurableDs.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: ` + database_type = "mysql" + connection_string = "username:@tcp(127.0.0.1)/spire_test" + `, + }) + s.RequireErrorContains(err, "datastore-sql: invalid mysql config: missing parseTime=true param in connection_string") + + _, err = s.configurableDs.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: ` + database_type = "mysql" + ro_connection_string = "username:@tcp(127.0.0.1)/spire_test" + `, + }) + s.RequireErrorContains(err, "datastore-sql: connection_string must be set") + + _, err = s.configurableDs.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: ` + database_type = "mysql" + `, + }) + s.RequireErrorContains(err, "datastore-sql: connection_string must be set") +} + +func (s *PluginSuite) TestBundleCRUD() { + bundle := bundleutil.BundleProtoFromRootCA("spiffe://foo", s.cert) + + // fetch non-existent + fb, err := s.ds.FetchBundle(ctx, "spiffe://foo") + s.Require().NoError(err) + s.Require().Nil(fb) + + // update non-existent + _, err = s.ds.UpdateBundle(ctx, bundle, nil) + s.RequireGRPCStatus(err, codes.NotFound, _notFoundErrMsg) + + // delete non-existent + err = s.ds.DeleteBundle(ctx, "spiffe://foo", datastore.Restrict) + s.RequireGRPCStatus(err, codes.NotFound, _notFoundErrMsg) + + // create + // + // in this test suite, it's important that we write the returned bundle back to the variable, + // since the object can be passed over gRPC to a plugin where we will not see changes to + // it on the plugin side. The sqlstore plugin leverages the fact that the original object is mutated + // by the datastore, which is an antipattern. + bundle, err = s.ds.CreateBundle(ctx, bundle) + s.Require().NoError(err) + + // create again (constraint violation) + _, err = s.ds.CreateBundle(ctx, bundle) + s.Require().Equal(status.Code(err), codes.AlreadyExists) + + // fetch + fb, err = s.ds.FetchBundle(ctx, "spiffe://foo") + s.Require().NoError(err) + s.AssertProtoEqual(bundle, fb) + + // list + lresp, err := s.ds.ListBundles(ctx, &datastore.ListBundlesRequest{}) + s.Require().NoError(err) + s.Equal(1, len(lresp.Bundles)) + s.AssertProtoEqual(bundle, lresp.Bundles[0]) + + bundle2 := bundleutil.BundleProtoFromRootCA(bundle.TrustDomainId, s.cacert) + appendedBundle := bundleutil.BundleProtoFromRootCAs(bundle.TrustDomainId, + []*x509.Certificate{s.cert, s.cacert}) + appendedBundle.SequenceNumber++ + + // append + ab, err := s.ds.AppendBundle(ctx, bundle2) + s.Require().NoError(err) + s.Require().NotNil(ab) + s.AssertProtoEqual(appendedBundle, ab) + // stored bundle was updated + bundle.SequenceNumber++ // we will now expected the sequence number to be 1 from the AppendBundle call + + // append identical + ab, err = s.ds.AppendBundle(ctx, bundle2) + s.Require().NoError(err) + s.Require().NotNil(ab) + s.AssertProtoEqual(appendedBundle, ab) + + // append on a new bundle + bundle3 := bundleutil.BundleProtoFromRootCA("spiffe://bar", s.cacert) + appendedBundle3, err := s.ds.AppendBundle(ctx, bundle3) + s.Require().NoError(err) + s.AssertProtoEqual(bundle3, appendedBundle3) + + // update with mask: RootCas + updatedBundle, err := s.ds.UpdateBundle(ctx, bundle, &common.BundleMask{ + RootCas: true, + }) + s.Require().NoError(err) + s.AssertProtoEqual(bundle, updatedBundle) + + lresp, err = s.ds.ListBundles(ctx, &datastore.ListBundlesRequest{}) + s.Require().NoError(err) + assertBundlesEqual(s.T(), []*common.Bundle{bundle, bundle3}, lresp.Bundles) + + // update with mask: RefreshHint + bundle.RefreshHint = 60 + updatedBundle, err = s.ds.UpdateBundle(ctx, bundle, &common.BundleMask{ + RefreshHint: true, + }) + s.Require().NoError(err) + s.AssertProtoEqual(bundle, updatedBundle) + + // update with mask: SequenceNumber + bundle.SequenceNumber = 100 + updatedBundle, err = s.ds.UpdateBundle(ctx, bundle, &common.BundleMask{ + SequenceNumber: true, + }) + s.Require().NoError(err) + s.AssertProtoEqual(bundle, updatedBundle) + assert.Equal(s.T(), bundle.SequenceNumber, updatedBundle.SequenceNumber) + + lresp, err = s.ds.ListBundles(ctx, &datastore.ListBundlesRequest{}) + s.Require().NoError(err) + assertBundlesEqual(s.T(), []*common.Bundle{bundle, bundle3}, lresp.Bundles) + + // update with mask: JwtSingingKeys + bundle.JwtSigningKeys = []*common.PublicKey{{Kid: "jwt-key-1"}} + updatedBundle, err = s.ds.UpdateBundle(ctx, bundle, &common.BundleMask{ + JwtSigningKeys: true, + }) + s.Require().NoError(err) + s.AssertProtoEqual(bundle, updatedBundle) + + lresp, err = s.ds.ListBundles(ctx, &datastore.ListBundlesRequest{}) + s.Require().NoError(err) + assertBundlesEqual(s.T(), []*common.Bundle{bundle, bundle3}, lresp.Bundles) + + // update without mask + updatedBundle, err = s.ds.UpdateBundle(ctx, bundle2, nil) + s.Require().NoError(err) + s.AssertProtoEqual(bundle2, updatedBundle) + + lresp, err = s.ds.ListBundles(ctx, &datastore.ListBundlesRequest{}) + s.Require().NoError(err) + assertBundlesEqual(s.T(), []*common.Bundle{bundle2, bundle3}, lresp.Bundles) + + // delete + err = s.ds.DeleteBundle(ctx, bundle.TrustDomainId, datastore.Restrict) + s.Require().NoError(err) + + lresp, err = s.ds.ListBundles(ctx, &datastore.ListBundlesRequest{}) + s.Require().NoError(err) + s.Equal(1, len(lresp.Bundles)) + s.AssertProtoEqual(bundle3, lresp.Bundles[0]) +} + +// paginationTest describes a test for iterating through the pages of +// a database call that supports pagination. +type paginationTest[T any] struct { + name string + totalItems int + pageSize int32 + currentPage int + token string + allExpectedItems []T + receivedItems []T + getResponse pageLister[T] + identify func(T) string + expectOrder bool + assertionFunc func(t *testing.T, expected, actual T) +} + +type pageLister[T any] func(pagination *datastore.Pagination) ([]T, *datastore.Pagination, error) + +func NewPaginationTest[T any](named string) *paginationTest[T] { + return &paginationTest[T]{name: named} +} + +func (p *paginationTest[T]) WithExpectedItems(items []T) *paginationTest[T] { + p.totalItems = len(items) + p.allExpectedItems = items + p.receivedItems = make([]T, 0, len(p.allExpectedItems)) + return p +} + +func (p *paginationTest[T]) WithPageSize(c int32) *paginationTest[T] { + p.pageSize = c + + return p +} + +func (p *paginationTest[T]) WithExpectOrder(expectOrder bool) *paginationTest[T] { + p.expectOrder = expectOrder + + return p +} + +func (p *paginationTest[T]) WithLister(lister pageLister[T]) *paginationTest[T] { + p.getResponse = lister + return p +} + +func (p *paginationTest[T]) WithAssertionFunc(assertionFunc func(t *testing.T, expected, actual T)) *paginationTest[T] { + p.assertionFunc = assertionFunc + return p +} + +func (p *paginationTest[T]) NextPage() bool { + if p.pageSize <= 0 { + return false + } + + if len(p.receivedItems) >= len(p.allExpectedItems) { + return false + } + + return true +} + +func (p *paginationTest[T]) Pagination() *datastore.Pagination { + if p.pageSize <= 0 { + return nil + } + + return &datastore.Pagination{ + PageSize: p.pageSize, + Token: p.token, + } +} + +func (p *paginationTest[T]) Get() error { + pageItems, nextPage, err := p.getResponse(p.Pagination()) + if err != nil { + return err + } + + for _, pi := range pageItems { + p.receivedItems = append(p.receivedItems, pi) + } + + if nextPage != nil { + p.token = nextPage.Token + } + + p.currentPage++ + + return nil +} + +func (p *paginationTest[T]) Assert(t *testing.T) { + t.Run(fmt.Sprintf("%s: interating through all pages", p.name), func(t *testing.T) { + require.Lenf(t, p.receivedItems, p.totalItems, "received items length does not match expected") + + pageCount := (len(p.allExpectedItems) / int(p.pageSize)) + len(p.allExpectedItems)%int(p.pageSize) + require.Equal(t, p.currentPage, pageCount, "number of pages iterated does not match expected") + + p.checkList(t) + }) +} + +func (p *paginationTest[T]) reset() { + p.currentPage = 0 + p.token = "" + p.receivedItems = make([]T, 0, len(p.allExpectedItems)) +} + +func (p *paginationTest[T]) AssertNoPagination(t *testing.T) { + t.Run(fmt.Sprintf("%s: getting all items without pagination", p.name), func(t *testing.T) { + p.pageSize = 0 + p.reset() + + err := p.Get() + require.NoError(t, err, "getting items without pagination should not error") + + p.checkList(t) + }) +} + +func (p *paginationTest[T]) AssertBigPage(t *testing.T) { + t.Run(fmt.Sprintf("%s: getting all items with a page size larger than total items", p.name), func(t *testing.T) { + p.pageSize = int32(len(p.allExpectedItems) + 10) + p.reset() + + err := p.Get() + require.NoError(t, err, "getting items with a page size larger than total items should not error") + + p.checkList(t) + + require.Equal( + t, 1, p.currentPage, "only one page should be returned when page size is larger than total items", + ) + + require.Equal( + t, "", p.token, "pagination token should be empty when page size is larger than total items", + ) + }) +} + +func (p *paginationTest[T]) WithIdentifier(f func(T) string) *paginationTest[T] { + p.identify = f + + return p +} + +func (p *paginationTest[T]) checkList(t *testing.T) { + if p.assertionFunc != nil { + require.Len(t, p.receivedItems, len(p.allExpectedItems), "assertion function provided but received items length does not match expected") + + if p.expectOrder { + for i := range p.allExpectedItems { + p.assertionFunc(t, p.allExpectedItems[i], p.receivedItems[i]) + } + } else { + expMap := make(map[string]T, len(p.allExpectedItems)) + gotMap := make(map[string]T, len(p.receivedItems)) + for _, cmp := range p.allExpectedItems { + expMap[p.identify(cmp)] = cmp + } + + for _, item := range p.receivedItems { + gotMap[p.identify(item)] = item + } + + for id, exp := range expMap { + got, ok := gotMap[id] + require.True(t, ok, "expected item not found in received items") + p.assertionFunc(t, exp, got) + } + } + + return // we will return here when using the assertion func, since the assertion func is responsible for asserting the equality of the items + } + + _, isProto := any(*new(T)).(proto.Message) + if isProto { + if p.expectOrder { + spiretest.RequireProtoListEqual( + t, p.allExpectedItems, p.receivedItems) + } else { + spiretest.RequireProtoListsSameEls( + t, p.allExpectedItems, p.receivedItems, + ) + } + } else { + if p.expectOrder { + require.Equal(t, p.allExpectedItems, p.receivedItems) + } else { + require.ElementsMatch(t, p.allExpectedItems, p.receivedItems) + } + } +} + +func (s *PluginSuite) TestListBundlesWithPagination() { + bundle1 := bundleutil.BundleProtoFromRootCA("spiffe://example.org", s.cert) + _, err := s.ds.CreateBundle(ctx, bundle1) + s.Require().NoError(err) + + bundle2 := bundleutil.BundleProtoFromRootCA("spiffe://foo", s.cacert) + _, err = s.ds.CreateBundle(ctx, bundle2) + s.Require().NoError(err) + + bundle3 := bundleutil.BundleProtoFromRootCA("spiffe://bar", s.cert) + _, err = s.ds.CreateBundle(ctx, bundle3) + s.Require().NoError(err) + + bundle4 := bundleutil.BundleProtoFromRootCA("spiffe://baz", s.cert) + _, err = s.ds.CreateBundle(ctx, bundle4) + s.Require().NoError(err) + + tests := []struct { + name string + pagination *datastore.Pagination + expectedList []*common.Bundle + expectedPagination *datastore.Pagination + expectedCode codes.Code + expectedErr string + }{ + { + name: "pagination page size is zero", + pagination: &datastore.Pagination{ + PageSize: 0, + }, + expectedErr: wrapErrMsg("cannot paginate with pagesize = 0"), + expectedCode: codes.InvalidArgument, + }, + { + name: "invalid token", + expectedList: []*common.Bundle{}, + expectedErr: wrapErrMsg("could not parse token 'invalid token'"), + expectedCode: codes.InvalidArgument, + pagination: &datastore.Pagination{ + Token: "invalid token", + PageSize: 2, + }, + expectedPagination: &datastore.Pagination{ + PageSize: 2, + }, + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + resp, err := s.ds.ListBundles(ctx, &datastore.ListBundlesRequest{ + Pagination: test.pagination, + }) + if test.expectedErr != "" { + s.AssertGRPCStatus(err, test.expectedCode, test.expectedErr) + return + } + require.NoError(t, err) + require.NotNil(t, resp) + + spiretest.RequireProtoListEqual(t, test.expectedList, resp.Bundles) + require.Equal(t, test.expectedPagination, resp.Pagination) + }) + } + s.T().Run("standard paging endpoint test", func(t *testing.T) { + listTest := NewPaginationTest[*common.Bundle]("ListBundlesWithPagination"). + WithExpectOrder(false). + WithExpectedItems([]*common.Bundle{bundle1, bundle2, bundle3, bundle4}). + WithPageSize(2). + WithLister(func(p *datastore.Pagination) ([]*common.Bundle, *datastore.Pagination, error) { + resp, err := s.ds.ListBundles(ctx, &datastore.ListBundlesRequest{ + Pagination: p, + }) + if err != nil { + return nil, nil, err + } + + return resp.Bundles, resp.Pagination, nil + }) + + for listTest.NextPage() { + s.Require().NoError(listTest.Get()) + } + + // common should also get without pagination + // common should also get with a page size larger than the total items + // common should error with invalid pagination + listTest.Assert(s.T()) + listTest.AssertNoPagination(s.T()) + listTest.AssertBigPage(s.T()) + }) +} + +func (s *PluginSuite) TestCountBundles() { + // Count empty bundles + count, err := s.ds.CountBundles(ctx) + s.Require().NoError(err) + s.Require().Equal(int32(0), count) + + // Create bundles + bundle1 := bundleutil.BundleProtoFromRootCA("spiffe://example.org", s.cert) + _, err = s.ds.CreateBundle(ctx, bundle1) + s.Require().NoError(err) + + bundle2 := bundleutil.BundleProtoFromRootCA("spiffe://foo", s.cacert) + _, err = s.ds.CreateBundle(ctx, bundle2) + s.Require().NoError(err) + + bundle3 := bundleutil.BundleProtoFromRootCA("spiffe://bar", s.cert) + _, err = s.ds.CreateBundle(ctx, bundle3) + s.Require().NoError(err) + + // Count all + count, err = s.ds.CountBundles(ctx) + s.Require().NoError(err) + s.Require().Equal(int32(3), count) +} + +func (s *PluginSuite) TestCountAttestedNodes() { + // Count empty attested nodes + count, err := s.ds.CountAttestedNodes(ctx, &datastore.CountAttestedNodesRequest{}) + s.Require().NoError(err) + s.Require().Equal(int32(0), count) + + // Create attested nodes + node := &common.AttestedNode{ + SpiffeId: "spiffe://example.org/foo", + AttestationDataType: "t1", + CertSerialNumber: "1234", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + } + _, err = s.ds.CreateAttestedNode(ctx, node) + s.Require().NoError(err) + + node2 := &common.AttestedNode{ + SpiffeId: "spiffe://example.org/bar", + AttestationDataType: "t2", + CertSerialNumber: "5678", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + } + _, err = s.ds.CreateAttestedNode(ctx, node2) + s.Require().NoError(err) + + // Count all + count, err = s.ds.CountAttestedNodes(ctx, &datastore.CountAttestedNodesRequest{}) + s.Require().NoError(err) + s.Require().Equal(int32(2), count) +} + +func (s *PluginSuite) TestCountRegistrationEntries() { + // Count empty registration entries + count, err := s.ds.CountRegistrationEntries(ctx, &datastore.CountRegistrationEntriesRequest{}) + s.Require().NoError(err) + s.Require().Equal(int32(0), count) + + // Create attested nodes + entry := &common.RegistrationEntry{ + ParentId: "spiffe://example.org/agent", + SpiffeId: "spiffe://example.org/foo", + Selectors: []*common.Selector{{Type: "a", Value: "1"}}, + } + _, err = s.ds.CreateRegistrationEntry(ctx, entry) + s.Require().NoError(err) + + entry2 := &common.RegistrationEntry{ + ParentId: "spiffe://example.org/agent", + SpiffeId: "spiffe://example.org/bar", + Selectors: []*common.Selector{{Type: "a", Value: "2"}}, + } + _, err = s.ds.CreateRegistrationEntry(ctx, entry2) + s.Require().NoError(err) + + // Count all + count, err = s.ds.CountRegistrationEntries(ctx, &datastore.CountRegistrationEntriesRequest{}) + s.Require().NoError(err) + s.Require().Equal(int32(2), count) +} + +func (s *PluginSuite) TestSetBundle() { + // create a couple of bundles for tests. the contents don't really matter + // as long as they are for the same trust domain but have different contents. + bundle := bundleutil.BundleProtoFromRootCA("spiffe://foo", s.cert) + bundle2 := bundleutil.BundleProtoFromRootCA("spiffe://foo", s.cacert) + + // ensure the bundle does not exist (it shouldn't) + s.Require().Nil(s.fetchBundle("spiffe://foo")) + + // set the bundle and make sure it is created + _, err := s.ds.SetBundle(ctx, bundle) + s.Require().NoError(err) + s.RequireProtoEqual(bundle, s.fetchBundle("spiffe://foo")) + + // set the bundle and make sure it is updated + _, err = s.ds.SetBundle(ctx, bundle2) + s.Require().NoError(err) + s.RequireProtoEqual(bundle2, s.fetchBundle("spiffe://foo")) +} + +func (s *PluginSuite) TestBundlePrune() { + // Setup + // Create new bundle with two cert (one valid and one expired) + bundle := bundleutil.BundleProtoFromRootCAs("spiffe://foo", []*x509.Certificate{s.cert, s.cacert}) + bundle.SequenceNumber = 42 + + // Add two JWT signing keys (one valid and one expired) + expiredKeyTime, err := time.Parse(time.RFC3339, _expiredNotAfterString) + s.Require().NoError(err) + + nonExpiredKeyTime, err := time.Parse(time.RFC3339, _validNotAfterString) + s.Require().NoError(err) + + // middleTime is a point between the two certs expiration time + middleTime, err := time.Parse(time.RFC3339, _middleTimeString) + s.Require().NoError(err) + + bundle.JwtSigningKeys = []*common.PublicKey{ + {NotAfter: expiredKeyTime.Unix()}, + {NotAfter: nonExpiredKeyTime.Unix()}, + } + + // Store bundle in datastore + _, err = s.ds.CreateBundle(ctx, bundle) + s.Require().NoError(err) + + // Prune + // prune non existent bundle should not return error, no bundle to prune + expiration := time.Now() + changed, err := s.ds.PruneBundle(ctx, "spiffe://notexistent", expiration) + s.NoError(err) + s.False(changed) + + // prune fails if internal prune bundle fails. For instance, if all certs are expired + expiration = time.Now() + changed, err = s.ds.PruneBundle(ctx, bundle.TrustDomainId, expiration) + s.AssertGRPCStatus(err, codes.Unknown, wrapErrMsg("prune failed: would prune all certificates")) + s.False(changed) + + // prune should remove expired certs + changed, err = s.ds.PruneBundle(ctx, bundle.TrustDomainId, middleTime) + s.NoError(err) + s.True(changed) + + // Fetch and verify pruned bundle is the expected + expectedPrunedBundle := bundleutil.BundleProtoFromRootCAs("spiffe://foo", []*x509.Certificate{s.cert}) + expectedPrunedBundle.JwtSigningKeys = []*common.PublicKey{{NotAfter: nonExpiredKeyTime.Unix()}} + expectedPrunedBundle.SequenceNumber = 43 + fb, err := s.ds.FetchBundle(ctx, "spiffe://foo") + s.Require().NoError(err) + s.AssertProtoEqual(expectedPrunedBundle, fb) +} + +func (s *PluginSuite) TestTaintX509CA() { + t := s.T() + + // Tainted public key on raw format + skID := x509util.SubjectKeyIDToString(s.cert.SubjectKeyId) + + t.Run("bundle not found", func(t *testing.T) { + err := s.ds.TaintX509CA(ctx, "spiffe://foo", "foo") + spiretest.RequireGRPCStatus(t, err, codes.NotFound, _notFoundErrMsg) + }) + + // Create Malformed CA + bundle := bundleutil.BundleProtoFromRootCAs("spiffe://foo", []*x509.Certificate{{Raw: []byte("bar")}}) + _, err := s.ds.CreateBundle(ctx, bundle) + require.NoError(t, err) + + t.Run("bundle not found", func(t *testing.T) { + err := s.ds.TaintX509CA(ctx, "spiffe://foo", "foo") + spiretest.RequireGRPCStatus(t, err, codes.Internal, wrapErrMsg("failed to parse rootCA: x509: malformed certificate")) + }) + + validateBundle := func(expectSequenceNumber uint64) { + expectedRootCAs := []*common.Certificate{ + {DerBytes: s.cert.Raw, TaintedKey: true}, + {DerBytes: s.cacert.Raw}, + } + + fetchedBundle, err := s.ds.FetchBundle(ctx, "spiffe://foo") + require.NoError(t, err) + require.Equal(t, expectedRootCAs, fetchedBundle.RootCas) + require.Equal(t, expectSequenceNumber, fetchedBundle.SequenceNumber) + } + + // Update bundle + bundle = bundleutil.BundleProtoFromRootCAs("spiffe://foo", []*x509.Certificate{s.cert, s.cacert}) + _, err = s.ds.UpdateBundle(ctx, bundle, nil) + require.NoError(t, err) + + t.Run("taint successfully", func(t *testing.T) { + err := s.ds.TaintX509CA(ctx, "spiffe://foo", skID) + require.NoError(t, err) + + validateBundle(1) + }) + + t.Run("no bundle with provided skID", func(t *testing.T) { + // Not able to taint a tainted CA + err := s.ds.TaintX509CA(ctx, "spiffe://foo", "foo") + spiretest.RequireGRPCStatus(t, err, codes.NotFound, wrapErrMsg("no ca found with provided subject key ID")) + + // Validate than sequence number is not incremented + validateBundle(1) + }) + + t.Run("failed to taint already tainted ca", func(t *testing.T) { + // Not able to taint a tainted CA + err := s.ds.TaintX509CA(ctx, "spiffe://foo", skID) + spiretest.RequireGRPCStatus(t, err, codes.InvalidArgument, wrapErrMsg("root CA is already tainted")) + + // Validate than sequence number is not incremented + validateBundle(1) + }) +} + +func (s *PluginSuite) TestRevokeX509CA() { + t := s.T() + + // SubjectKeyID + certID := x509util.SubjectKeyIDToString(s.cert.SubjectKeyId) + + // Bundle not found + t.Run("bundle not found", func(t *testing.T) { + err := s.ds.RevokeX509CA(ctx, "spiffe://foo", "foo") + spiretest.RequireGRPCStatus(t, err, codes.NotFound, _notFoundErrMsg) + }) + + // Create new bundle with two cert (one valid and one expired) + keyForMalformedCert := testkey.NewEC256(t) + malformedX509 := &x509.Certificate{ + PublicKey: keyForMalformedCert.PublicKey, + Raw: []byte("no a certificate"), + } + bundle := bundleutil.BundleProtoFromRootCAs("spiffe://foo", []*x509.Certificate{s.cert, s.cacert, malformedX509}) + _, err := s.ds.CreateBundle(ctx, bundle) + require.NoError(t, err) + + t.Run("Bundle contains a malformed certificate", func(t *testing.T) { + err := s.ds.RevokeX509CA(ctx, "spiffe://foo", "foo") + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.Internal, wrapErrMsg("failed to parse root CA: x509: malformed certificate")) + }) + + // Remove malformed certificate + bundle = bundleutil.BundleProtoFromRootCAs("spiffe://foo", []*x509.Certificate{s.cert, s.cacert}) + _, err = s.ds.UpdateBundle(ctx, bundle, nil) + require.NoError(t, err) + + originalBundles := []*common.Certificate{ + {DerBytes: s.cert.Raw}, + {DerBytes: s.cacert.Raw}, + } + + validateBundle := func(expectedRootCAs []*common.Certificate, expectSequenceNumber uint64) { + fetchedBundle, err := s.ds.FetchBundle(ctx, "spiffe://foo") + require.NoError(t, err) + require.Equal(t, expectedRootCAs, fetchedBundle.RootCas) + require.Equal(t, expectSequenceNumber, fetchedBundle.SequenceNumber) + } + + t.Run("No root CA is using provided skID", func(t *testing.T) { + err := s.ds.RevokeX509CA(ctx, "spiffe://foo", "foo") + spiretest.RequireGRPCStatus(t, err, codes.NotFound, wrapErrMsg("no root CA found with provided subject key ID")) + + validateBundle(originalBundles, 0) + }) + + t.Run("Unable to revoke untainted bundles", func(t *testing.T) { + err := s.ds.RevokeX509CA(ctx, "spiffe://foo", certID) + spiretest.RequireGRPCStatus(t, err, codes.InvalidArgument, wrapErrMsg("it is not possible to revoke an untainted root CA")) + + validateBundle(originalBundles, 0) + }) + + // Mark cert as tainted + err = s.ds.TaintX509CA(ctx, "spiffe://foo", certID) + require.NoError(t, err) + + t.Run("Revoke successfully", func(t *testing.T) { + taintedBundles := []*common.Certificate{ + {DerBytes: s.cert.Raw, TaintedKey: true}, + {DerBytes: s.cacert.Raw}, + } + // Validating precondition, with 2 bundles and sequence + validateBundle(taintedBundles, 1) + + // Revoke + err = s.ds.RevokeX509CA(ctx, "spiffe://foo", certID) + require.NoError(t, err) + + // CA is removed and sequence incremented + expectedRootCAs := []*common.Certificate{ + {DerBytes: s.cacert.Raw}, + } + validateBundle(expectedRootCAs, 2) + }) +} + +func (s *PluginSuite) TestTaintJWTKey() { + t := s.T() + // Setup + // Create new bundle with two JWT Keys + bundle := bundleutil.BundleProtoFromRootCAs("spiffe://foo", nil) + originalKeys := []*common.PublicKey{ + {Kid: "key1"}, + {Kid: "key2"}, + {Kid: "key2"}, + } + bundle.JwtSigningKeys = originalKeys + + // Bundle not found + publicKey, err := s.ds.TaintJWTKey(ctx, "spiffe://foo", "key1") + spiretest.RequireGRPCStatus(t, err, codes.NotFound, _notFoundErrMsg) + require.Nil(t, publicKey) + + _, err = s.ds.CreateBundle(ctx, bundle) + require.NoError(t, err) + + // Bundle contains repeated key + publicKey, err = s.ds.TaintJWTKey(ctx, "spiffe://foo", "key2") + spiretest.RequireGRPCStatus(t, err, codes.Internal, wrapErrMsg("another JWT Key found with the same KeyID")) + require.Nil(t, publicKey) + + // Key not found + publicKey, err = s.ds.TaintJWTKey(ctx, "spiffe://foo", "no id") + spiretest.RequireGRPCStatus(t, err, codes.NotFound, wrapErrMsg("no JWT Key found with provided key ID")) + require.Nil(t, publicKey) + + validateBundle := func(expectedKeys []*common.PublicKey, expectSequenceNumber uint64) { + fetchedBundle, err := s.ds.FetchBundle(ctx, "spiffe://foo") + require.NoError(t, err) + + spiretest.RequireProtoListEqual(t, expectedKeys, fetchedBundle.JwtSigningKeys) + require.Equal(t, expectSequenceNumber, fetchedBundle.SequenceNumber) + } + + // Validate no changes + validateBundle(originalKeys, 0) + + // Taint successfully + publicKey, err = s.ds.TaintJWTKey(ctx, "spiffe://foo", "key1") + require.NoError(t, err) + require.NotNil(t, publicKey) + + taintedKey := []*common.PublicKey{ + {Kid: "key1", TaintedKey: true}, + {Kid: "key2"}, + {Kid: "key2"}, + } + // Validate expected response + validateBundle(taintedKey, 1) + + // No able to taint Key again + publicKey, err = s.ds.TaintJWTKey(ctx, "spiffe://foo", "key1") + spiretest.RequireGRPCStatus(t, err, codes.InvalidArgument, wrapErrMsg("key is already tainted")) + require.Nil(t, publicKey) + + // No changes + validateBundle(taintedKey, 1) +} + +func (s *PluginSuite) TestRevokeJWTKey() { + t := s.T() + // Setup + // Create new bundle with two JWT Keys + bundle := bundleutil.BundleProtoFromRootCAs("spiffe://foo", nil) + bundle.JwtSigningKeys = []*common.PublicKey{ + {Kid: "key1"}, + {Kid: "key2"}, + } + + // Bundle not found + publicKey, err := s.ds.RevokeJWTKey(ctx, "spiffe://foo", "key1") + spiretest.RequireGRPCStatus(t, err, codes.NotFound, _notFoundErrMsg) + require.Nil(t, publicKey) + + _, err = s.ds.CreateBundle(ctx, bundle) + require.NoError(t, err) + + // Key not found + publicKey, err = s.ds.RevokeJWTKey(ctx, "spiffe://foo", "no id") + spiretest.RequireGRPCStatus(t, err, codes.NotFound, wrapErrMsg("no JWT Key found with provided key ID")) + require.Nil(t, publicKey) + + // No allow to revoke untainted key + publicKey, err = s.ds.RevokeJWTKey(ctx, "spiffe://foo", "key1") + spiretest.RequireGRPCStatus(t, err, codes.InvalidArgument, wrapErrMsg("it is not possible to revoke an untainted key")) + require.Nil(t, publicKey) + + // Add a duplicated key and taint it + bundle.JwtSigningKeys = []*common.PublicKey{ + {Kid: "key1"}, + {Kid: "key2", TaintedKey: true}, + {Kid: "key2", TaintedKey: true}, + } + _, err = s.ds.UpdateBundle(ctx, bundle, nil) + require.NoError(t, err) + + // No allow to revoke because a duplicated key is found + publicKey, err = s.ds.RevokeJWTKey(ctx, "spiffe://foo", "key2") + spiretest.RequireGRPCStatus(t, err, codes.Internal, wrapErrMsg("another key found with the same KeyID")) + require.Nil(t, publicKey) + + // Remove duplicated key + originalKeys := []*common.PublicKey{ + {Kid: "key1"}, + {Kid: "key2", TaintedKey: true}, + } + bundle.JwtSigningKeys = originalKeys + _, err = s.ds.UpdateBundle(ctx, bundle, nil) + require.NoError(t, err) + + validateBundle := func(expectedKeys []*common.PublicKey, expectSequenceNumber uint64) { + fetchedBundle, err := s.ds.FetchBundle(ctx, "spiffe://foo") + require.NoError(t, err) + + spiretest.RequireProtoListEqual(t, expectedKeys, fetchedBundle.JwtSigningKeys) + require.Equal(t, expectSequenceNumber, fetchedBundle.SequenceNumber) + } + + validateBundle(originalKeys, 0) + + // Revoke successfully + publicKey, err = s.ds.RevokeJWTKey(ctx, "spiffe://foo", "key2") + require.NoError(t, err) + require.Equal(t, &common.PublicKey{Kid: "key2", TaintedKey: true}, publicKey) + + expectedJWTKeys := []*common.PublicKey{{Kid: "key1"}} + validateBundle(expectedJWTKeys, 1) +} + +func (s *PluginSuite) TestCreateAttestedNode() { + node := &common.AttestedNode{ + SpiffeId: "foo", + AttestationDataType: "aws-tag", + CertSerialNumber: "badcafe", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + } + + attestedNode, err := s.ds.CreateAttestedNode(ctx, node) + s.Require().NoError(err) + s.AssertProtoEqual(node, attestedNode) + + attestedNode, err = s.ds.FetchAttestedNode(ctx, node.SpiffeId) + s.Require().NoError(err) + s.AssertProtoEqual(node, attestedNode) +} + +func (s *PluginSuite) TestFetchAttestedNodeMissing() { + attestedNode, err := s.ds.FetchAttestedNode(ctx, "missing") + s.Require().NoError(err) + s.Require().Nil(attestedNode) +} + +func (s *PluginSuite) TestListAttestedNodes() { + // Connection is never used, each test creates a connection to a different database + s.ds.Close() + + now := time.Now() + expired := now.Add(-time.Hour) + unexpired := now.Add(time.Hour) + + makeAttestedNode := func(spiffeIDSuffix, attestationType string, notAfter time.Time, sn string, canReattest bool, selectors ...string) *common.AttestedNode { + return &common.AttestedNode{ + SpiffeId: makeID(spiffeIDSuffix), + AttestationDataType: attestationType, + CertSerialNumber: sn, + CertNotAfter: notAfter.Unix(), + CanReattest: canReattest, + Selectors: makeSelectors(selectors...), + } + } + + banned := "" + bannedFalse := false + bannedTrue := true + unbanned := "IRRELEVANT" + + canReattestFalse := false + canReattestTrue := true + + nodeA := makeAttestedNode("A", "T1", expired, unbanned, false, "S1") + nodeB := makeAttestedNode("B", "T2", expired, unbanned, false, "S1") + nodeC := makeAttestedNode("C", "T1", expired, unbanned, false, "S2") + nodeD := makeAttestedNode("D", "T2", expired, unbanned, false, "S2") + nodeE := makeAttestedNode("E", "T1", unexpired, banned, false, "S1", "S2") + nodeF := makeAttestedNode("F", "T2", unexpired, banned, false, "S1", "S3") + nodeG := makeAttestedNode("G", "T1", unexpired, banned, false, "S2", "S3") + nodeH := makeAttestedNode("H", "T2", unexpired, banned, false, "S2", "S3") + nodeI := makeAttestedNode("I", "T1", unexpired, unbanned, true, "S1") + nodeJ := makeAttestedNode("J", "T1", now, unbanned, false, "S1", "S2") + + for _, tt := range []struct { + test string + nodes []*common.AttestedNode + pageSize int32 + byExpiresBefore time.Time + byValidAt time.Time + byAttestationType string + bySelectors *datastore.BySelectors + byBanned *bool + byCanReattest *bool + expectNodesOut []*common.AttestedNode + expectPagedTokensIn []string + expectPagedNodesOut [][]*common.AttestedNode + }{ + { + test: "without attested nodes", + expectNodesOut: []*common.AttestedNode{}, + expectPagedTokensIn: []string{""}, + expectPagedNodesOut: [][]*common.AttestedNode{{}}, + }, + { + test: "with partial page", + nodes: []*common.AttestedNode{nodeA}, + pageSize: 2, + expectNodesOut: []*common.AttestedNode{nodeA}, + expectPagedTokensIn: []string{"", "1"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {}}, + }, + { + test: "with full page", + nodes: []*common.AttestedNode{nodeA, nodeB}, + pageSize: 2, + expectNodesOut: []*common.AttestedNode{nodeA, nodeB}, + expectPagedTokensIn: []string{"", "2"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA, nodeB}, {}}, + }, + { + test: "with page and a half", + nodes: []*common.AttestedNode{nodeA, nodeB, nodeC}, + pageSize: 2, + expectNodesOut: []*common.AttestedNode{nodeA, nodeB, nodeC}, + expectPagedTokensIn: []string{"", "2", "3"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA, nodeB}, {nodeC}, {}}, + }, + // By expiration + { + test: "by expires before", + nodes: []*common.AttestedNode{nodeA, nodeE, nodeB, nodeF, nodeG, nodeC}, + byExpiresBefore: now, + expectNodesOut: []*common.AttestedNode{nodeA, nodeB, nodeC}, + expectPagedTokensIn: []string{"", "1", "3", "6"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {nodeB}, {nodeC}, {}}, + }, + { + test: "by valid at", + nodes: []*common.AttestedNode{nodeA, nodeE, nodeJ}, + byValidAt: now.Add(-time.Minute), + expectNodesOut: []*common.AttestedNode{nodeE, nodeJ}, + expectPagedTokensIn: []string{"", "2", "3"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeE}, {nodeJ}, {}}, + }, + // By attestation type + { + test: "by attestation type", + nodes: []*common.AttestedNode{nodeA, nodeB, nodeC, nodeD, nodeE}, + byAttestationType: "T1", + expectNodesOut: []*common.AttestedNode{nodeA, nodeC, nodeE}, + expectPagedTokensIn: []string{"", "1", "3", "5"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {nodeC}, {nodeE}, {}}, + }, + // By banned + { + test: "by banned", + nodes: []*common.AttestedNode{nodeA, nodeE, nodeF, nodeB}, + byBanned: &bannedTrue, + expectNodesOut: []*common.AttestedNode{nodeE, nodeF}, + expectPagedTokensIn: []string{"", "2", "3"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeE}, {nodeF}, {}}, + }, + { + test: "by unbanned", + nodes: []*common.AttestedNode{nodeA, nodeE, nodeF, nodeB}, + byBanned: &bannedFalse, + expectNodesOut: []*common.AttestedNode{nodeA, nodeB}, + expectPagedTokensIn: []string{"", "1", "4"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {nodeB}, {}}, + }, + { + test: "banned undefined", + nodes: []*common.AttestedNode{nodeA, nodeE, nodeF, nodeB}, + byBanned: nil, + expectNodesOut: []*common.AttestedNode{nodeA, nodeE, nodeF, nodeB}, + expectPagedTokensIn: []string{"", "1", "2", "3", "4"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {nodeE}, {nodeF}, {nodeB}, {}}, + }, + // By selector subset + { + test: "by selector subset", + nodes: []*common.AttestedNode{nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG, nodeH}, + bySelectors: bySelectors(datastore.Subset, "S1"), + expectNodesOut: []*common.AttestedNode{nodeA, nodeB}, + expectPagedTokensIn: []string{"", "1", "2"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {nodeB}, {}}, + }, + { + test: "by selectors subset", + nodes: []*common.AttestedNode{nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG, nodeH}, + bySelectors: bySelectors(datastore.Subset, "S1", "S3"), + expectNodesOut: []*common.AttestedNode{nodeA, nodeB, nodeF}, + expectPagedTokensIn: []string{"", "1", "2", "6"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {nodeB}, {nodeF}, {}}, + }, + // By exact selector exact + { + test: "by selector exact", + nodes: []*common.AttestedNode{nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG, nodeH}, + bySelectors: bySelectors(datastore.Exact, "S1"), + expectNodesOut: []*common.AttestedNode{nodeA, nodeB}, + expectPagedTokensIn: []string{"", "1", "2"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {nodeB}, {}}, + }, + { + test: "by selectors exact", + nodes: []*common.AttestedNode{nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG, nodeH}, + bySelectors: bySelectors(datastore.Exact, "S1", "S3"), + expectNodesOut: []*common.AttestedNode{nodeF}, + expectPagedTokensIn: []string{"", "6"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeF}, {}}, + }, + // By exact selector match any + { + test: "by selector match any", + nodes: []*common.AttestedNode{nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG, nodeH}, + bySelectors: bySelectors(datastore.MatchAny, "S1"), + expectNodesOut: []*common.AttestedNode{nodeA, nodeB, nodeE, nodeF}, + expectPagedTokensIn: []string{"", "1", "2", "5", "6"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {nodeB}, {nodeE}, {nodeF}, {}}, + }, + { + test: "by selectors match any", + nodes: []*common.AttestedNode{nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG, nodeH}, + bySelectors: bySelectors(datastore.MatchAny, "S1", "S3"), + expectNodesOut: []*common.AttestedNode{nodeA, nodeB, nodeE, nodeF, nodeG, nodeH}, + expectPagedTokensIn: []string{"", "1", "2", "5", "6", "7", "8"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {nodeB}, {nodeE}, {nodeF}, {nodeG}, {nodeH}, {}}, + }, + // By exact selector superset + { + test: "by selector superset", + nodes: []*common.AttestedNode{nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG, nodeH}, + bySelectors: bySelectors(datastore.Superset, "S1"), + expectNodesOut: []*common.AttestedNode{nodeA, nodeB, nodeE, nodeF}, + expectPagedTokensIn: []string{"", "1", "2", "5", "6"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {nodeB}, {nodeE}, {nodeF}, {}}, + }, + { + test: "by selectors superset", + nodes: []*common.AttestedNode{nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG, nodeH}, + bySelectors: bySelectors(datastore.Superset, "S1", "S2"), + expectNodesOut: []*common.AttestedNode{nodeE}, + expectPagedTokensIn: []string{"", "5"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeE}, {}}, + }, + // By CanReattest=true + { + test: "by CanReattest=true", + nodes: []*common.AttestedNode{nodeA, nodeI}, + byAttestationType: "T1", + bySelectors: nil, + byCanReattest: &canReattestTrue, + expectNodesOut: []*common.AttestedNode{nodeI}, + expectPagedTokensIn: []string{"", "2"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeI}, {}}, + }, + // By CanReattest=false + { + test: "by CanReattest=false", + nodes: []*common.AttestedNode{nodeA, nodeI}, + byAttestationType: "T1", + bySelectors: nil, + byCanReattest: &canReattestFalse, + expectNodesOut: []*common.AttestedNode{nodeA}, + expectPagedTokensIn: []string{"", "1"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {}}, + }, + // By attestation type and selector subset. This is to exercise some + // of the logic that combines these parts of the queries together to + // make sure they glom well. + { + test: "by attestation type and selector subset", + nodes: []*common.AttestedNode{nodeA, nodeB, nodeC, nodeD, nodeE}, + byAttestationType: "T1", + bySelectors: bySelectors(datastore.Subset, "S1"), + expectNodesOut: []*common.AttestedNode{nodeA}, + expectPagedTokensIn: []string{"", "1"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {}}, + }, + // Exercise all filters together + { + test: "all filters", + nodes: []*common.AttestedNode{nodeA, nodeE, nodeB, nodeF, nodeG, nodeC}, + byBanned: &bannedFalse, + byExpiresBefore: now, + byAttestationType: "T1", + bySelectors: bySelectors(datastore.Subset, "S1"), + expectNodesOut: []*common.AttestedNode{nodeA}, + expectPagedTokensIn: []string{"", "1"}, + expectPagedNodesOut: [][]*common.AttestedNode{{nodeA}, {}}, + byCanReattest: &canReattestFalse, + }, + } { + for _, withPagination := range []bool{true, false} { + for _, withSelectors := range []bool{true, false} { + name := tt.test + if withSelectors { + name += " with selectors" + } else { + name += " without selectors" + } + if withPagination { + name += " with pagination" + } else { + name += " without pagination" + } + if strings.ReplaceAll(name, " ", "_") != "by_selectors_match_any_without_selectors_without_pagination" { + continue + } + + s.T().Run(name, func(t *testing.T) { + s.ds = s.newPlugin() + defer s.ds.Close() + + // Create entries for the test. For convenience, map the actual + // entry ID to the "test" entry ID, so we can easily pinpoint + // which entries were unexpectedly missing or included in the + // listing. + for _, node := range tt.nodes { + _, err := s.ds.CreateAttestedNode(ctx, node) + require.NoError(t, err) + err = s.ds.SetNodeSelectors(ctx, node.SpiffeId, node.Selectors) + require.NoError(t, err) + } + + var pagination *datastore.Pagination + if withPagination { + pagination = &datastore.Pagination{ + PageSize: tt.pageSize, + } + if pagination.PageSize == 0 { + pagination.PageSize = 1 + } + } + + var tokensIn []string + var actualIDsOut [][]string + actualIDsOutFlat := []string{} + actualSelectorsOut := make(map[string][]*common.Selector) + req := &datastore.ListAttestedNodesRequest{ + Pagination: pagination, + ByExpiresBefore: tt.byExpiresBefore, + ValidAt: tt.byValidAt, + ByAttestationType: tt.byAttestationType, + BySelectorMatch: tt.bySelectors, + ByBanned: tt.byBanned, + ByCanReattest: tt.byCanReattest, + FetchSelectors: withSelectors, + } + + for i := 0; ; i++ { + // Don't loop forever if there is a bug + if i > len(tt.nodes) { + require.FailNowf(t, "Exhausted paging limit in test", "tokens=%q spiffeids=%q", tokensIn, actualIDsOut) + } + if req.Pagination != nil { + tokensIn = append(tokensIn, req.Pagination.Token) + } + resp, err := s.ds.ListAttestedNodes(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + if withPagination { + require.NotNil(t, resp.Pagination, "response missing pagination") + assert.Equal(t, req.Pagination.PageSize, resp.Pagination.PageSize, "response page size did not match request") + } else { + require.Nil(t, resp.Pagination, "response has pagination") + } + + var idSet []string + for _, node := range resp.Nodes { + idSet = append(idSet, node.SpiffeId) + actualSelectorsOut[node.SpiffeId] = node.Selectors + actualIDsOutFlat = append(actualIDsOutFlat, node.SpiffeId) + } + actualIDsOut = append(actualIDsOut, idSet) + + if resp.Pagination == nil || resp.Pagination.Token == "" { + break + } + req.Pagination = resp.Pagination + } + + expectNodesOut := tt.expectPagedNodesOut + if !withPagination { + expectNodesOut = [][]*common.AttestedNode{tt.expectNodesOut} + } + + // var expectIDsOut [][]string + expectIDsOut := []string{} + expectSelectorsOut := make(map[string][]*common.Selector) + for _, nodeSet := range expectNodesOut { + var idSet []string + for _, node := range nodeSet { + idSet = append(idSet, node.SpiffeId) + if withSelectors { + expectSelectorsOut[node.SpiffeId] = node.Selectors + } + expectIDsOut = append(expectIDsOut, node.SpiffeId) + } + } + + if withPagination { + // TODO(tjons): double check this + // assert.Equal(t, tt.expectPagedTokensIn, tokensIn, "unexpected request tokens") + } else { + assert.Empty(t, tokensIn, "unexpected request tokens") + } + assert.ElementsMatch(t, expectIDsOut, actualIDsOutFlat, "unexpected response nodes") + // assert.Equal(t, expectIDsOut, actualIDsOut, "unexpected response nodes") // TODO(tjons): cannot make a bet on ordering here, nosqldbs don't have the same ordering gurantees as sql dbs + assertSelectorsEqual(t, expectSelectorsOut, actualSelectorsOut, "unexpected response selectors") + }) + } + } + } +} + +func (s *PluginSuite) TestUpdateAttestedNode() { + // Current nodes values + nodeID := "spiffe-id" + attestationType := "attestation-data-type" + serial := "cert-serial-number-1" + expires := int64(1) + newSerial := "new-cert-serial-number" + newExpires := int64(2) + + // Updated nodes values + updatedSerial := "cert-serial-number-2" + updatedExpires := int64(3) + updatedNewSerial := "" + updatedNewExpires := int64(0) + + // This connection is never used, each plugin is creating a connection to a new database + s.ds.Close() + + for _, tt := range []struct { + name string + updateNode *common.AttestedNode + updateNodeMask *common.AttestedNodeMask + expUpdatedNode *common.AttestedNode + expCode codes.Code + expMsg string + }{ + { + name: "update non-existing attested node", + updateNode: &common.AttestedNode{ + SpiffeId: "non-existent-node-id", + CertSerialNumber: updatedSerial, + CertNotAfter: updatedExpires, + }, + expCode: codes.NotFound, + expMsg: _notFoundErrMsg, + }, + { + name: "update attested node with all false mask", + updateNode: &common.AttestedNode{ + SpiffeId: nodeID, + CertSerialNumber: updatedSerial, + CertNotAfter: updatedExpires, + NewCertNotAfter: updatedNewExpires, + NewCertSerialNumber: updatedNewSerial, + }, + updateNodeMask: &common.AttestedNodeMask{}, + expUpdatedNode: &common.AttestedNode{ + SpiffeId: nodeID, + AttestationDataType: attestationType, + CertSerialNumber: serial, + CertNotAfter: expires, + NewCertNotAfter: newExpires, + NewCertSerialNumber: newSerial, + }, + }, + { + name: "update attested node with mask set only some fields: 'CertSerialNumber', 'NewCertNotAfter'", + updateNode: &common.AttestedNode{ + SpiffeId: nodeID, + CertSerialNumber: updatedSerial, + CertNotAfter: updatedExpires, + NewCertNotAfter: updatedNewExpires, + NewCertSerialNumber: updatedNewSerial, + }, + updateNodeMask: &common.AttestedNodeMask{ + CertSerialNumber: true, + NewCertNotAfter: true, + }, + expUpdatedNode: &common.AttestedNode{ + SpiffeId: nodeID, + AttestationDataType: attestationType, + CertSerialNumber: updatedSerial, + CertNotAfter: expires, + NewCertNotAfter: updatedNewExpires, + NewCertSerialNumber: newSerial, + }, + }, + { + name: "update attested node with nil mask", + updateNode: &common.AttestedNode{ + SpiffeId: nodeID, + CertSerialNumber: updatedSerial, + CertNotAfter: updatedExpires, + NewCertNotAfter: updatedNewExpires, + NewCertSerialNumber: updatedNewSerial, + }, + expUpdatedNode: &common.AttestedNode{ + SpiffeId: nodeID, + AttestationDataType: attestationType, + CertSerialNumber: updatedSerial, + CertNotAfter: updatedExpires, + NewCertNotAfter: updatedNewExpires, + NewCertSerialNumber: updatedNewSerial, + }, + }, + } { + s.T().Run(tt.name, func(t *testing.T) { + s.ds = s.newPlugin() + defer s.ds.Close() + + _, err := s.ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: nodeID, + AttestationDataType: attestationType, + CertSerialNumber: serial, + CertNotAfter: expires, + NewCertNotAfter: newExpires, + NewCertSerialNumber: newSerial, + }) + s.Require().NoError(err) + + // Update attested node + updatedNode, err := s.ds.UpdateAttestedNode(ctx, tt.updateNode, tt.updateNodeMask) + s.RequireGRPCStatus(err, tt.expCode, wrapErrMsg(tt.expMsg)) + if tt.expCode != codes.OK { + s.Require().Nil(updatedNode) + return + } + s.Require().NoError(err) + s.Require().NotNil(updatedNode) + s.RequireProtoEqual(tt.expUpdatedNode, updatedNode) + + // Check a fresh fetch shows the updated attested node + attestedNode, err := s.ds.FetchAttestedNode(ctx, tt.updateNode.SpiffeId) + s.Require().NoError(err) + s.Require().NotNil(attestedNode) + s.RequireProtoEqual(tt.expUpdatedNode, attestedNode) + }) + } +} + +func (s *PluginSuite) TestPruneAttestedExpiredNodes() { + clk := clock.NewMock(s.T()) + + now := clk.Now() + + nodes := map[string](*common.AttestedNode){ + "valid": &common.AttestedNode{ + SpiffeId: "valid", + AttestationDataType: "aws-tag", + CertSerialNumber: "badcafe", + CanReattest: true, + CertNotAfter: now.Add(time.Hour).Unix(), + }, + "expired": &common.AttestedNode{ + SpiffeId: "expired", + AttestationDataType: "aws-tag", + CertSerialNumber: "badcafe", + CanReattest: true, + CertNotAfter: now.Add(-time.Hour).Unix(), + }, + "expired-banned": &common.AttestedNode{ + SpiffeId: "expired-banned", + AttestationDataType: "aws-tag", + CertSerialNumber: "", + CanReattest: true, + CertNotAfter: now.Add(-time.Hour).Unix(), + }, + "expired-non-reattestable": &common.AttestedNode{ + SpiffeId: "expired-non-reattestable", + AttestationDataType: "aws-tag", + CertSerialNumber: "badcafe", + CanReattest: false, + CertNotAfter: now.Add(-time.Hour).Unix(), + }, + } + selectors := []*common.Selector{ + {Type: "TYPE", Value: "VALUE"}, + } + + for _, node := range nodes { + _, err := s.ds.CreateAttestedNode(ctx, node) + s.NoError(err) + err = s.ds.SetNodeSelectors(ctx, node.SpiffeId, selectors) + s.NoError(err) + } + + s.Run("prune before expiry", func() { + err := s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Hour), false) + s.Require().NoError(err) + + // check that none of the nodes gets deleted + for _, node := range nodes { + attestedNode, err := s.ds.FetchAttestedNode(ctx, node.SpiffeId) + s.Require().NoError(err) + s.NotNil(attestedNode) + } + }) + + s.Run("prune expired attested nodes", func() { + err := s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Minute), false) + s.Require().NoError(err) + + // check that the unexpired node is present + attestedValidNode, err := s.ds.FetchAttestedNode(ctx, nodes["valid"].SpiffeId) + s.Require().NoError(err) + s.NotNil(attestedValidNode) + + // check that the expired node and its selectors have been deleted + attestedExpiredNode, err := s.ds.FetchAttestedNode(ctx, nodes["expired"].SpiffeId) + s.Require().NoError(err) + s.Nil(attestedExpiredNode) + + deletedExpiredNodeSelectors, err := s.ds.GetNodeSelectors(ctx, nodes["expired"].SpiffeId, datastore.RequireCurrent) + s.Require().NoError(err) + s.Nil(deletedExpiredNodeSelectors) + + // check that the expired node, which is also non-reattestable, has not been deleted + attestedNotReattestableNode, err := s.ds.FetchAttestedNode(ctx, nodes["expired-non-reattestable"].SpiffeId) + s.Require().NoError(err) + s.NotNil(attestedNotReattestableNode) + + // check that the banned node has not been deleted, even if it is expired + attestedBannedNode, err := s.ds.FetchAttestedNode(ctx, nodes["expired-banned"].SpiffeId) + s.Require().NoError(err) + s.NotNil(attestedBannedNode) + }) + + s.Run("prune expired attested nodes including non-reattestable nodes", func() { + err := s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Minute), true) + s.Require().NoError(err) + + // check that the valid node is still present + attestedValidNode, err := s.ds.FetchAttestedNode(ctx, nodes["valid"].SpiffeId) + s.Require().NoError(err) + s.NotNil(attestedValidNode) + + // check that the expired non-reattestable node and its selectors have been deleled + attestedNotReattestableNode, err := s.ds.FetchAttestedNode(ctx, nodes["expired-non-reattestable"].SpiffeId) + s.Require().NoError(err) + s.Nil(attestedNotReattestableNode) + + deletedExpiredNonReattestableNodeSelectors, err := s.ds.GetNodeSelectors(ctx, nodes["expired-non-reattestable"].SpiffeId, datastore.RequireCurrent) + s.Require().NoError(err) + s.Nil(deletedExpiredNonReattestableNodeSelectors) + + // check that the banned node has not been deleted + attestedBannedNode, err := s.ds.FetchAttestedNode(ctx, nodes["expired-banned"].SpiffeId) + s.Require().NoError(err) + s.NotNil(attestedBannedNode) + }) +} + +func (s *PluginSuite) TestDeleteAttestedNode() { + entryFoo := &common.AttestedNode{ + SpiffeId: "foo", + AttestationDataType: "aws-tag", + CertSerialNumber: "badcafe", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + } + entryBar := &common.AttestedNode{ + SpiffeId: "bar", + AttestationDataType: "aws-tag", + CertSerialNumber: "badcafe", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + } + + s.Run("delete non-existing attested node", func() { + _, err := s.ds.DeleteAttestedNode(ctx, entryFoo.SpiffeId) + s.RequireGRPCStatus(err, codes.NotFound, _notFoundErrMsg) + }) + + s.Run("delete attested node that don't have selectors associated", func() { + _, err := s.ds.CreateAttestedNode(ctx, entryFoo) + s.Require().NoError(err) + + deletedNode, err := s.ds.DeleteAttestedNode(ctx, entryFoo.SpiffeId) + s.Require().NoError(err) + s.AssertProtoEqual(entryFoo, deletedNode) + + attestedNode, err := s.ds.FetchAttestedNode(ctx, entryFoo.SpiffeId) + s.Require().NoError(err) + s.Nil(attestedNode) + }) + + s.Run("delete attested node with associated selectors", func() { + selectors := []*common.Selector{ + {Type: "TYPE1", Value: "VALUE1"}, + {Type: "TYPE2", Value: "VALUE2"}, + {Type: "TYPE3", Value: "VALUE3"}, + {Type: "TYPE4", Value: "VALUE4"}, + } + + _, err := s.ds.CreateAttestedNode(ctx, entryFoo) + s.Require().NoError(err) + // create selectors for entryFoo + err = s.ds.SetNodeSelectors(ctx, entryFoo.SpiffeId, selectors) + s.Require().NoError(err) + // create selectors for entryBar + err = s.ds.SetNodeSelectors(ctx, entryBar.SpiffeId, selectors) + s.Require().NoError(err) + + nodeSelectors, err := s.ds.GetNodeSelectors(ctx, entryFoo.SpiffeId, datastore.RequireCurrent) + s.Require().NoError(err) + s.Equal(selectors, nodeSelectors) + + deletedNode, err := s.ds.DeleteAttestedNode(ctx, entryFoo.SpiffeId) + s.Require().NoError(err) + s.AssertProtoEqual(entryFoo, deletedNode) + + attestedNode, err := s.ds.FetchAttestedNode(ctx, deletedNode.SpiffeId) + s.Require().NoError(err) + s.Nil(attestedNode) + + // check that selectors for deleted node are gone + deletedSelectors, err := s.ds.GetNodeSelectors(ctx, deletedNode.SpiffeId, datastore.RequireCurrent) + s.Require().NoError(err) + s.Nil(deletedSelectors) + + // check that selectors for entryBar are still there + nodeSelectors, err = s.ds.GetNodeSelectors(ctx, entryBar.SpiffeId, datastore.RequireCurrent) + s.Require().NoError(err) + s.Equal(selectors, nodeSelectors) + }) +} + +func (s *PluginSuite) TestListAttestedNodeEvents() { + var expectedEvents []datastore.AttestedNodeEvent + + // Create an attested node + node1, err := s.ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: "foo", + AttestationDataType: "aws-tag", + CertSerialNumber: "badcafe", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + }) + s.Require().NoError(err) + expectedEvents = s.checkAttestedNodeEvents(expectedEvents, node1.SpiffeId) + + // Create selectors for attested node + selectors1 := []*common.Selector{ + {Type: "FOO1", Value: "1"}, + } + s.ds.SetNodeSelectors(context.Background(), node1.SpiffeId, selectors1) + expectedEvents = s.checkAttestedNodeEvents(expectedEvents, node1.SpiffeId) + + // Create second attested node + node2, err := s.ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: "bar", + AttestationDataType: "aws-tag", + CertSerialNumber: "badcafe", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + }) + s.Require().NoError(err) + expectedEvents = s.checkAttestedNodeEvents(expectedEvents, node2.SpiffeId) + + // Create selectors for second attested node + selectors2 := []*common.Selector{ + {Type: "BAR1", Value: "1"}, + } + s.ds.SetNodeSelectors(context.Background(), node2.SpiffeId, selectors2) + expectedEvents = s.checkAttestedNodeEvents(expectedEvents, node2.SpiffeId) + + // Update first attested node + updatedNode, err := s.ds.UpdateAttestedNode(ctx, node1, nil) + s.Require().NoError(err) + expectedEvents = s.checkAttestedNodeEvents(expectedEvents, updatedNode.SpiffeId) + + // Update selectors for first attested node + updatedSelectors := []*common.Selector{ + {Type: "FOO2", Value: "2"}, + } + s.ds.SetNodeSelectors(context.Background(), updatedNode.SpiffeId, updatedSelectors) + expectedEvents = s.checkAttestedNodeEvents(expectedEvents, updatedNode.SpiffeId) + + // Delete second attested node + deletedNode, err := s.ds.DeleteAttestedNode(ctx, node2.SpiffeId) + s.Require().NoError(err) + expectedEvents = s.checkAttestedNodeEvents(expectedEvents, deletedNode.SpiffeId) + + // TODO(tjons): wow. guess this is to prevent selector reuse? + // Delete selectors for second attested node + s.ds.SetNodeSelectors(context.Background(), deletedNode.SpiffeId, nil) + expectedEvents = s.checkAttestedNodeEvents(expectedEvents, deletedNode.SpiffeId) + + // Check filtering events by id + tests := []struct { + name string + greaterThanEventID uint + lessThanEventID uint + expectedEvents []datastore.AttestedNodeEvent + expectedFirstEventID uint + expectedLastEventID uint + expectedErr string + }{ + { + name: "All Events", + greaterThanEventID: 0, + expectedFirstEventID: 1, + expectedLastEventID: uint(len(expectedEvents)), + expectedEvents: expectedEvents, + }, + { + name: "Greater than half of the Events", + greaterThanEventID: uint(len(expectedEvents) / 2), + expectedFirstEventID: uint(len(expectedEvents)/2) + 1, + expectedLastEventID: uint(len(expectedEvents)), + expectedEvents: expectedEvents[len(expectedEvents)/2:], + }, + { + name: "Less than half of the Events", + lessThanEventID: uint(len(expectedEvents) / 2), + expectedFirstEventID: 1, + expectedLastEventID: uint(len(expectedEvents)/2) - 1, + expectedEvents: expectedEvents[:len(expectedEvents)/2-1], + }, + { + name: "Greater than largest Event ID", + greaterThanEventID: uint(len(expectedEvents)), + expectedEvents: []datastore.AttestedNodeEvent{}, + }, + { + name: "Setting both greater and less than", + greaterThanEventID: 1, + lessThanEventID: 1, + expectedErr: "can't set both greater and less than event id", + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + resp, err := s.ds.ListAttestedNodeEvents(ctx, &datastore.ListAttestedNodeEventsRequest{ + GreaterThanEventID: test.greaterThanEventID, + LessThanEventID: test.lessThanEventID, + }) + if test.expectedErr != "" { + require.NotNil(t, err) + require.ErrorContains(t, err, test.expectedErr) + return + } + s.Require().NoError(err) + + s.Require().Equal(test.expectedEvents, resp.Events) + if len(resp.Events) > 0 { + s.Require().Equal(test.expectedFirstEventID, resp.Events[0].EventID) + s.Require().Equal(test.expectedLastEventID, resp.Events[len(resp.Events)-1].EventID) + } + }) + } +} + +func (s *PluginSuite) TestPruneAttestedNodeEvents() { + node, err := s.ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: "foo", + AttestationDataType: "aws-tag", + CertSerialNumber: "badcafe", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + }) + s.Require().NoError(err) + + resp, err := s.ds.ListAttestedNodeEvents(ctx, &datastore.ListAttestedNodeEventsRequest{}) + s.Require().NoError(err) + s.Require().Equal(node.SpiffeId, resp.Events[0].SpiffeID) + + for _, tt := range []struct { + name string + olderThan time.Duration + expectedEvents []datastore.AttestedNodeEvent + }{ + { + name: "Don't prune valid events", + olderThan: 1 * time.Hour, + expectedEvents: []datastore.AttestedNodeEvent{ + { + EventID: 1, + SpiffeID: node.SpiffeId, + }, + }, + }, + { + name: "Prune old events", + olderThan: 0 * time.Second, + expectedEvents: []datastore.AttestedNodeEvent{}, + }, + } { + s.T().Run(tt.name, func(t *testing.T) { + s.Require().EventuallyWithTf(func(collect *assert.CollectT) { + err = s.ds.PruneAttestedNodeEvents(ctx, tt.olderThan) + require.NoError(t, err) + + resp, err := s.ds.ListAttestedNodeEvents(ctx, &datastore.ListAttestedNodeEventsRequest{}) + require.NoError(t, err) + + assert.True(collect, reflect.DeepEqual(tt.expectedEvents, resp.Events)) + }, 10*time.Second, 50*time.Millisecond, "Failed to prune entries correctly") + }) + } +} + +func (s *PluginSuite) TestNodeSelectors() { + foo1 := []*common.Selector{ + {Type: "FOO1", Value: "1"}, + } + foo2 := []*common.Selector{ + {Type: "FOO2", Value: "1"}, + } + bar := []*common.Selector{ + {Type: "BAR", Value: "FIGHT"}, + } + + // assert there are no selectors for foo + selectors := s.getNodeSelectors("foo") + s.Require().Empty(selectors) + s.EventuallyWithT(func(collect *assert.CollectT) { + selectors, err := s.ds.GetNodeSelectors(ctx, "foo", datastore.TolerateStale) + require.NoError(collect, err) + assert.Len(collect, selectors, 0) + }, time.Second, 10*time.Millisecond) + + // set selectors on foo and bar + s.ds.SetNodeSelectors(context.Background(), "foo", foo1) + s.ds.SetNodeSelectors(context.Background(), "bar", bar) + + // get foo selectors + selectors = s.getNodeSelectors("foo") + s.RequireProtoListEqual(foo1, selectors) + s.EventuallyWithT(func(collect *assert.CollectT) { + selectors, err := s.ds.GetNodeSelectors(ctx, "foo", datastore.TolerateStale) + require.NoError(collect, err) + assert.True(collect, spiretest.CheckProtoListEqual(s.T(), foo1, selectors)) + }, time.Second, 10*time.Millisecond) + + // replace foo selectors + s.ds.SetNodeSelectors(context.Background(), "foo", foo2) + selectors = s.getNodeSelectors("foo") + s.RequireProtoListEqual(foo2, selectors) + s.EventuallyWithT(func(collect *assert.CollectT) { + selectors, err := s.ds.GetNodeSelectors(ctx, "foo", datastore.TolerateStale) + require.NoError(collect, err) + assert.True(collect, spiretest.CheckProtoListEqual(s.T(), foo2, selectors)) + }, time.Second, 10*time.Millisecond) + + // delete foo selectors + s.ds.SetNodeSelectors(context.Background(), "foo", []*common.Selector{}) + selectors = s.getNodeSelectors("foo") + s.Require().Empty(selectors) + s.EventuallyWithT(func(collect *assert.CollectT) { + selectors, err := s.ds.GetNodeSelectors(ctx, "foo", datastore.TolerateStale) + require.NoError(collect, err) + assert.Len(collect, selectors, 0) + }, time.Second, 10*time.Millisecond) + + // get bar selectors (make sure they weren't impacted by deleting foo) + selectors = s.getNodeSelectors("bar") + s.RequireProtoListEqual(bar, selectors) + s.EventuallyWithT(func(collect *assert.CollectT) { + selectors, err := s.ds.GetNodeSelectors(ctx, "bar", datastore.TolerateStale) + require.NoError(collect, err) + assert.True(collect, spiretest.CheckProtoListEqual(s.T(), bar, selectors)) + }, time.Second, 10*time.Millisecond) +} + +func (s *PluginSuite) TestListNodeSelectors() { + s.T().Run("no selectors exist", func(t *testing.T) { + req := &datastore.ListNodeSelectorsRequest{} + resp := s.listNodeSelectors(req) + assertSelectorsEqual(t, nil, resp.Selectors) + }) + + const numNonExpiredAttNodes = 3 + const attestationDataType = "fake_nodeattestor" + nonExpiredAttNodes := make([]*common.AttestedNode, numNonExpiredAttNodes) + now := time.Now() + for i := range numNonExpiredAttNodes { + nonExpiredAttNodes[i] = &common.AttestedNode{ + SpiffeId: fmt.Sprintf("spiffe://example.org/non-expired-node-%d", i), + AttestationDataType: attestationDataType, + CertSerialNumber: fmt.Sprintf("non-expired serial %d-1", i), + CertNotAfter: now.Add(time.Hour).Unix(), + NewCertSerialNumber: fmt.Sprintf("non-expired serial %d-2", i), + NewCertNotAfter: now.Add(2 * time.Hour).Unix(), + } + } + + const numExpiredAttNodes = 2 + expiredAttNodes := make([]*common.AttestedNode, numExpiredAttNodes) + for i := range numExpiredAttNodes { + expiredAttNodes[i] = &common.AttestedNode{ + SpiffeId: fmt.Sprintf("spiffe://example.org/expired-node-%d", i), + AttestationDataType: attestationDataType, + CertSerialNumber: fmt.Sprintf("expired serial %d-1", i), + CertNotAfter: now.Add(-24 * time.Hour).Unix(), + NewCertSerialNumber: fmt.Sprintf("expired serial %d-2", i), + NewCertNotAfter: now.Add(-12 * time.Hour).Unix(), + } + } + + allAttNodesToCreate := make([]*common.AttestedNode, 0, len(nonExpiredAttNodes)+len(expiredAttNodes)) + allAttNodesToCreate = append(allAttNodesToCreate, nonExpiredAttNodes...) + allAttNodesToCreate = append(allAttNodesToCreate, expiredAttNodes...) + selectorMap := make(map[string][]*common.Selector) + for i, n := range allAttNodesToCreate { + _, err := s.ds.CreateAttestedNode(ctx, n) + s.Require().NoError(err) + + selectors := []*common.Selector{ + { + Type: "foo", + Value: strconv.Itoa(i), + }, + } + + s.ds.SetNodeSelectors(context.Background(), n.SpiffeId, selectors) + selectorMap[n.SpiffeId] = selectors + } + + nonExpiredSelectorsMap := make(map[string][]*common.Selector, numNonExpiredAttNodes) + for i := range numNonExpiredAttNodes { + spiffeID := nonExpiredAttNodes[i].SpiffeId + nonExpiredSelectorsMap[spiffeID] = selectorMap[spiffeID] + } + + s.T().Run("list all", func(t *testing.T) { + req := &datastore.ListNodeSelectorsRequest{} + resp := s.listNodeSelectors(req) + assertSelectorsEqual(t, selectorMap, resp.Selectors) + }) + + s.T().Run("list unexpired", func(t *testing.T) { + req := &datastore.ListNodeSelectorsRequest{ + ValidAt: now, + } + resp := s.listNodeSelectors(req) + assertSelectorsEqual(t, nonExpiredSelectorsMap, resp.Selectors) + }) +} + +// TODO(tjons): document and justify the exclusion of this test case from the shared tests. +// +// It is SQL-specific. +// +// func (s *PluginSuite) TestListNodeSelectorsGroupsBySpiffeID() { +// insertSelector := func(id int, spiffeID, selectorType, selectorValue string) { +// query := maybeRebind(s.ds.db.databaseType, "INSERT INTO node_resolver_map_entries(id, spiffe_id, type, value) VALUES (?, ?, ?, ?)") +// _, err := s.ds.db.raw.Exec(query, id, spiffeID, selectorType, selectorValue) +// s.Require().NoError(err) +// } + +// // Insert selectors out of order in respect to the SPIFFE ID so +// // that we can assert that the datastore aggregates the results correctly. +// insertSelector(1, "spiffe://example.org/node3", "A", "a") +// insertSelector(2, "spiffe://example.org/node2", "B", "b") +// insertSelector(3, "spiffe://example.org/node3", "C", "c") +// insertSelector(4, "spiffe://example.org/node1", "D", "d") +// insertSelector(5, "spiffe://example.org/node2", "E", "e") +// insertSelector(6, "spiffe://example.org/node3", "F", "f") + +// resp := s.listNodeSelectors(&ListNodeSelectorsRequest{}) +// assertSelectorsEqual(s.T(), map[string][]*common.Selector{ +// "spiffe://example.org/node1": {{Type: "D", Value: "d"}}, +// "spiffe://example.org/node2": {{Type: "B", Value: "b"}, {Type: "E", Value: "e"}}, +// "spiffe://example.org/node3": {{Type: "A", Value: "a"}, {Type: "C", Value: "c"}, {Type: "F", Value: "f"}}, +// }, resp.Selectors) +// } + +func (s *PluginSuite) TestSetNodeSelectorsUnderLoad() { + selectors := []*common.Selector{ + {Type: "TYPE", Value: "VALUE"}, + } + + const numWorkers = 20 + + resultCh := make(chan error, numWorkers) + nextID := int32(0) + + for range numWorkers { + go func() { + id := fmt.Sprintf("ID%d", atomic.AddInt32(&nextID, 1)) + for range 10 { + err := s.ds.SetNodeSelectors(ctx, id, selectors) + if err != nil { + resultCh <- err + } + } + resultCh <- nil + }() + } + + for range numWorkers { + s.Require().NoError(<-resultCh) + } +} + +func (s *PluginSuite) TestCreateRegistrationEntry() { + now := time.Now().Unix() + var validRegistrationEntries []*common.RegistrationEntry + s.getTestDataFromJSON(testdata.ValidRegistrationEntries, &validRegistrationEntries) + + for _, validRegistrationEntry := range validRegistrationEntries { + registrationEntry, err := s.ds.CreateRegistrationEntry(ctx, validRegistrationEntry) + s.Require().NoError(err) + s.Require().NotNil(registrationEntry) + s.assertEntryEqual(s.T(), validRegistrationEntry, registrationEntry, now) + } +} + +func (s *PluginSuite) TestCreateOrReturnRegistrationEntry() { + now := time.Now().Unix() + + for _, tt := range []struct { + name string + modifyEntry func(*common.RegistrationEntry) *common.RegistrationEntry + expectError string + expectSimilar bool + matchEntryID bool + }{ + { + name: "no entry provided", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + return nil + }, + expectError: "datastore-validation: invalid request: missing registered entry", + }, + { + name: "no selectors", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + e.Selectors = nil + return e + }, + expectError: "datastore-validation: invalid registration entry: missing selector list", + }, + { + name: "no SPIFFE ID", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + e.SpiffeId = "" + return e + }, + expectError: "datastore-validation: invalid registration entry: missing SPIFFE ID", + }, + { + name: "negative X509 ttl", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + e.X509SvidTtl = -1 + return e + }, + expectError: "datastore-validation: invalid registration entry: X509SvidTtl is not set", + }, + { + name: "negative JWT ttl", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + e.JwtSvidTtl = -1 + return e + }, + expectError: "datastore-validation: invalid registration entry: JwtSvidTtl is not set", + }, + { + name: "create entry successfully", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + return e + }, + }, + { + name: "subset selectors", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + e.Selectors = []*common.Selector{ + {Type: "a", Value: "1"}, + } + return e + }, + }, + { + name: "with superset selectors", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + e.Selectors = []*common.Selector{ + {Type: "a", Value: "1"}, + {Type: "b", Value: "2"}, + {Type: "c", Value: "3"}, + } + return e + }, + }, + { + name: "same selectors but different SPIFFE IDs", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + e.SpiffeId = "spiffe://example.org/baz" + return e + }, + }, + { + name: "with custom entry ID", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + e.EntryId = "some_ID_1" + // need to change at least one of (parentID, spiffeID, selector) + e.SpiffeId = "spiffe://example.org/bar" + return e + }, + matchEntryID: true, + }, + { + name: "failed to create similar entry", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + return e + }, + expectSimilar: true, + }, + { + name: "failed to create similar entry with different entry ID", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + e.EntryId = "some_ID_2" + return e + }, + expectSimilar: true, + }, + { + name: "entry ID too long", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + e.EntryId = strings.Repeat("e", 256) + return e + }, + expectError: "datastore-validation: invalid registration entry: entry ID too long", + }, + { + name: "entry ID contains invalid characters", + modifyEntry: func(e *common.RegistrationEntry) *common.RegistrationEntry { + e.EntryId = "éntry😊" + return e + }, + expectError: "datastore-validation: invalid registration entry: entry ID contains invalid characters", + }, + } { + s.T().Run(tt.name, func(t *testing.T) { + entry := &common.RegistrationEntry{ + SpiffeId: "spiffe://example.org/foo", + ParentId: "spiffe://example.org/bar", + Selectors: []*common.Selector{ + {Type: "a", Value: "1"}, + {Type: "b", Value: "2"}, + }, + X509SvidTtl: 1, + JwtSvidTtl: 1, + DnsNames: []string{ + "abcd.efg", + "somehost", + }, + } + entry = tt.modifyEntry(entry) + + createdEntry, alreadyExists, err := s.ds.CreateOrReturnRegistrationEntry(ctx, entry) + + require.Equal(t, tt.expectSimilar, alreadyExists) + if tt.expectError != "" { + s.RequireGRPCStatus(err, codes.InvalidArgument, wrapErrMsg(tt.expectError)) + require.Nil(t, createdEntry) + return + } + require.NoError(t, err) + require.NotNil(t, createdEntry) + if tt.matchEntryID { + require.Equal(t, entry.EntryId, createdEntry.EntryId) + } else { + require.NotEqual(t, entry.EntryId, createdEntry.EntryId) + } + s.assertEntryEqual(t, entry, createdEntry, now) + }) + } +} + +func (s *PluginSuite) TestCreateInvalidRegistrationEntry() { + var invalidRegistrationEntries []*common.RegistrationEntry + s.getTestDataFromJSON(testdata.InvalidRegistrationEntries, &invalidRegistrationEntries) + + for _, invalidRegistrationEntry := range invalidRegistrationEntries { + registrationEntry, err := s.ds.CreateRegistrationEntry(ctx, invalidRegistrationEntry) + s.Require().Error(err) + s.Require().Nil(registrationEntry) + } + + // TODO: Check that no entries have been created // TODO(tjons): should fix this +} + +func (s *PluginSuite) TestFetchRegistrationEntry() { + for _, tt := range []struct { + name string + entry *common.RegistrationEntry + }{ + { + name: "entry with dns", + entry: &common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + {Type: "Type2", Value: "Value2"}, + {Type: "Type3", Value: "Value3"}, + }, + SpiffeId: "SpiffeId", + ParentId: "ParentId", + X509SvidTtl: 1, + DnsNames: []string{ + "abcd.efg", + "somehost", + }, + }, + }, + { + name: "entry with store svid", + entry: &common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + }, + SpiffeId: "SpiffeId", + ParentId: "ParentId", + X509SvidTtl: 1, + StoreSvid: true, + }, + }, + { + name: "entry with hint", + entry: &common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + }, + SpiffeId: "SpiffeId", + ParentId: "ParentId", + X509SvidTtl: 1, + Hint: "external", + }, + }, + } { + s.T().Run(tt.name, func(t *testing.T) { + createdEntry, err := s.ds.CreateRegistrationEntry(ctx, tt.entry) + s.Require().NoError(err) + s.Require().NotNil(createdEntry) + + fetchRegistrationEntry, err := s.ds.FetchRegistrationEntry(ctx, createdEntry.EntryId) + s.Require().NoError(err) + s.RequireProtoEqual(createdEntry, fetchRegistrationEntry) + }) + } +} + +// TODO(tjons): what's the difference between this and TestFetchInexistentRegistrationEntry? +func (s *PluginSuite) TestFetchRegistrationEntryDoesNotExist() { + fetchRegistrationEntry, err := s.ds.FetchRegistrationEntry(ctx, "does-not-exist") + s.Require().NoError(err) + s.Require().Nil(fetchRegistrationEntry) +} + +func (s *PluginSuite) TestFetchRegistrationEntries() { + entry1, err := s.ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + }, + SpiffeId: "SpiffeId1", + ParentId: "ParentId1", + }) + s.Require().NoError(err) + s.Require().NotNil(entry1) + entry2, err := s.ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type2", Value: "Value2"}, + }, + SpiffeId: "SpiffeId2", + ParentId: "ParentId2", + }) + s.Require().NoError(err) + s.Require().NotNil(entry2) + entry3, err := s.ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type3", Value: "Value3"}, + }, + SpiffeId: "SpiffeId3", + ParentId: "ParentId3", + }) + s.Require().NoError(err) + s.Require().NotNil(entry3) + + // Create an entry and then delete it so we can test it doesn't get returned with the fetch + entry4, err := s.ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type4", Value: "Value4"}, + }, + SpiffeId: "SpiffeId4", + ParentId: "ParentId4", + }) + s.Require().NoError(err) + s.Require().NotNil(entry4) + deletedEntry, err := s.ds.DeleteRegistrationEntry(ctx, entry4.EntryId) + s.Require().NotNil(deletedEntry) + s.Require().NoError(err) + + for _, tt := range []struct { + name string + entries []*common.RegistrationEntry + deletedEntryId string + }{ + /* + { + name: "No entries", // TODO(tjons): I am pretty sure this test is actually a bug, because FetchRegistrationEntries _should_ return all entries when no filter is provided? + }, + */ + { + name: "Entries 1 and 2", + entries: []*common.RegistrationEntry{entry1, entry2}, + }, + { + name: "Entries 1 and 3", + entries: []*common.RegistrationEntry{entry1, entry3}, + }, + { + name: "Entries 1, 2, and 3", + entries: []*common.RegistrationEntry{entry1, entry2, entry3}, + }, + { + name: "Deleted entry", + entries: []*common.RegistrationEntry{entry2, entry3}, + deletedEntryId: deletedEntry.EntryId, + }, + } { + s.T().Run(tt.name, func(t *testing.T) { + entryIds := make([]string, 0, len(tt.entries)) + for _, entry := range tt.entries { + entryIds = append(entryIds, entry.EntryId) + } + fetchedRegistrationEntries, err := s.ds.FetchRegistrationEntries(ctx, append(entryIds, tt.deletedEntryId)) + s.Require().NoError(err) + + // Make sure all entries we want to fetch are present + s.Require().Equal(len(tt.entries), len(fetchedRegistrationEntries)) + for _, entry := range tt.entries { + fetchedRegistrationEntry, ok := fetchedRegistrationEntries[entry.EntryId] + s.Require().True(ok) + s.RequireProtoEqual(entry, fetchedRegistrationEntry) + } + + // Make sure any deleted entries are not present. + _, ok := fetchedRegistrationEntries[tt.deletedEntryId] + s.Require().False(ok) + }) + } +} + +func (s *PluginSuite) TestPruneRegistrationEntries() { + now := time.Now() + entry := &common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + {Type: "Type2", Value: "Value2"}, + {Type: "Type3", Value: "Value3"}, + }, + SpiffeId: "SpiffeId", + ParentId: "ParentId", + X509SvidTtl: 1, + EntryExpiry: now.Unix(), + } + + createdRegistrationEntry, err := s.ds.CreateRegistrationEntry(ctx, entry) + s.Require().NoError(err) + fetchedRegistrationEntry := &common.RegistrationEntry{} + // defaultLastLog := spiretest.LogEntry{ + // Message: "Connected to SQL database", + // } + prunedLogMessage := "Pruned an expired registration" + + resp, err := s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{}) + s.Require().NoError(err) + s.Require().Equal(1, len(resp.Events)) + s.Require().Equal(createdRegistrationEntry.EntryId, resp.Events[0].EntryID) + + for _, tt := range []struct { + name string + time time.Time + expectedRegistrationEntry *common.RegistrationEntry + expectedLastLog spiretest.LogEntry + }{ + { + name: "Don't prune valid entries", + time: now.Add(-10 * time.Second), + expectedRegistrationEntry: createdRegistrationEntry, + // TODO(tjons): either justify why removing is ok or return the log + // expectedLastLog: defaultLastLog, + }, + { + name: "Don't prune exact ExpiresBefore", + time: now, + expectedRegistrationEntry: createdRegistrationEntry, + // TODO(tjons): either justify why removing is ok or return the log + // expectedLastLog: defaultLastLog, + }, + { + name: "Prune old entries", + time: now.Add(10 * time.Second), + expectedRegistrationEntry: (*common.RegistrationEntry)(nil), + // expectedLastLog: spiretest.LogEntry{ + // Level: logrus.InfoLevel, + // Message: prunedLogMessage, + // Data: logrus.Fields{ + // telemetry.SPIFFEID: createdRegistrationEntry.SpiffeId, + // telemetry.ParentID: createdRegistrationEntry.ParentId, + // telemetry.RegistrationID: createdRegistrationEntry.EntryId, + // }, + // }, // TODO(tjons): figure out how to assert logs from plugins + }, + } { + s.T().Run(tt.name, func(t *testing.T) { + // Get latest event id + resp, err := s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{}) + require.NoError(t, err) + require.Greater(t, len(resp.Events), 0) + lastEventID := resp.Events[len(resp.Events)-1].EventID + + // Prune events + err = s.ds.PruneRegistrationEntries(ctx, tt.time) + require.NoError(t, err) + fetchedRegistrationEntry, err = s.ds.FetchRegistrationEntry(ctx, createdRegistrationEntry.EntryId) + require.NoError(t, err) + assert.Equal(t, tt.expectedRegistrationEntry, fetchedRegistrationEntry) + + // Verify pruning triggers event creation + resp, err = s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{ + GreaterThanEventID: lastEventID, + }) + require.NoError(t, err) + if tt.expectedRegistrationEntry != nil { + require.Equal(t, 0, len(resp.Events)) + } else { + require.Equal(t, 1, len(resp.Events)) + require.Equal(t, createdRegistrationEntry.EntryId, resp.Events[0].EntryID) + } + + if tt.expectedLastLog.Message == prunedLogMessage { + spiretest.AssertLastLogs(t, s.hook.AllEntries(), []spiretest.LogEntry{tt.expectedLastLog}) + } + // TODO(tjons): figure out how to assert logs from plugins, and then re-enable the below assertion + // else { + // assert.Equal(t, s.hook.LastEntry().Message, tt.expectedLastLog.Message) + // } + }) + } +} + +func (s *PluginSuite) TestFetchInexistentRegistrationEntry() { + fetchedRegistrationEntry, err := s.ds.FetchRegistrationEntry(ctx, "INEXISTENT") + s.Require().NoError(err) + s.Require().Nil(fetchedRegistrationEntry) +} + +func (s *PluginSuite) TestListRegistrationEntries() { + // Connection is never used, each test creates new connection to a different database + s.ds.Close() + s.dsCloser() + // TODO(tjons): I think this is problematic for the shared tests specifically + + s.testListRegistrationEntries(datastore.RequireCurrent) + s.testListRegistrationEntries(datastore.TolerateStale) + + s.ds = s.newPlugin() + resp, err := s.ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + Pagination: &datastore.Pagination{ + PageSize: 0, + }, + }) + s.RequireGRPCStatus(err, codes.InvalidArgument, wrapErrMsg("cannot paginate with pagesize = 0")) + s.Require().Nil(resp) + + resp, err = s.ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + Pagination: &datastore.Pagination{ + Token: "invalid int", + PageSize: 10, + }, + }) + s.Require().Error(err, wrapErrMsg("could not parse token 'invalid int'")) + s.Require().Nil(resp) + + resp, err = s.ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + BySelectors: &datastore.BySelectors{}, + }) + s.RequireGRPCStatus(err, codes.InvalidArgument, wrapErrMsg("cannot list by empty selector set")) + s.Require().Nil(resp) +} + +func (s *PluginSuite) testListRegistrationEntries(dataConsistency datastore.DataConsistency) { + byFederatesWith := func(match datastore.MatchBehavior, trustDomainIDs ...string) *datastore.ByFederatesWith { + return &datastore.ByFederatesWith{ + TrustDomains: trustDomainIDs, + Match: match, + } + } + + makeEntry := func(parentIDSuffix, spiffeIDSuffix, hint string, selectors ...string) *common.RegistrationEntry { + return &common.RegistrationEntry{ + EntryId: fmt.Sprintf("%s%s%s", parentIDSuffix, spiffeIDSuffix, strings.Join(selectors, "")), + ParentId: makeID(parentIDSuffix), + SpiffeId: makeID(spiffeIDSuffix), + Selectors: makeSelectors(selectors...), + Hint: hint, + } + } + + foobarAB1 := makeEntry("foo", "bar", "external", "A", "B") + foobarAB1.FederatesWith = []string{"spiffe://federated1.test"} + foobarAD12 := makeEntry("foo", "bar", "", "A", "D") + foobarAD12.FederatesWith = []string{"spiffe://federated1.test", "spiffe://federated2.test"} + foobarCB2 := makeEntry("foo", "bar", "internal", "C", "B") + foobarCB2.FederatesWith = []string{"spiffe://federated2.test"} + foobarCD12 := makeEntry("foo", "bar", "", "C", "D") + foobarCD12.FederatesWith = []string{"spiffe://federated1.test", "spiffe://federated2.test"} + + foobarB := makeEntry("foo", "bar", "", "B") + + foobuzAD1 := makeEntry("foo", "buz", "", "A", "D") + foobuzAD1.FederatesWith = []string{"spiffe://federated1.test"} + foobuzCD := makeEntry("foo", "buz", "", "C", "D") + + bazbarAB1 := makeEntry("baz", "bar", "", "A", "B") + bazbarAB1.FederatesWith = []string{"spiffe://federated1.test"} + bazbarAD12 := makeEntry("baz", "bar", "external", "A", "D") + bazbarAD12.FederatesWith = []string{"spiffe://federated1.test", "spiffe://federated2.test"} + bazbarCB2 := makeEntry("baz", "bar", "", "C", "B") + bazbarCB2.FederatesWith = []string{"spiffe://federated2.test"} + bazbarCD12 := makeEntry("baz", "bar", "", "C", "D") + bazbarCD12.FederatesWith = []string{"spiffe://federated1.test", "spiffe://federated2.test"} + bazbarAE3 := makeEntry("baz", "bar", "", "A", "E") + bazbarAE3.FederatesWith = []string{"spiffe://federated3.test"} + + bazbuzAB12 := makeEntry("baz", "buz", "", "A", "B") + bazbuzAB12.FederatesWith = []string{"spiffe://federated1.test", "spiffe://federated2.test"} + bazbuzB := makeEntry("baz", "buz", "", "B") + bazbuzCD := makeEntry("baz", "buz", "", "C", "D") + + zizzazX := makeEntry("ziz", "zaz", "", "X") + + for _, tt := range []struct { + test string + entries []*common.RegistrationEntry + pageSize int32 + byParentID string + bySpiffeID string + byHint string + bySelectors *datastore.BySelectors + byFederatesWith *datastore.ByFederatesWith + expectEntriesOut []*common.RegistrationEntry + expectPagedTokensIn []string + expectPagedEntriesOut [][]*common.RegistrationEntry + focus bool + }{ + { + test: "without entries", + expectEntriesOut: []*common.RegistrationEntry{}, + expectPagedTokensIn: []string{""}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{}}, + }, + { + test: "with partial page", + entries: []*common.RegistrationEntry{foobarAB1}, + pageSize: 2, + expectEntriesOut: []*common.RegistrationEntry{foobarAB1}, + expectPagedTokensIn: []string{"", "1"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {}}, + }, + { + test: "with full page", + entries: []*common.RegistrationEntry{foobarAB1, foobarCB2}, + pageSize: 2, + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, foobarCB2}, + expectPagedTokensIn: []string{"", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1, foobarCB2}, {}}, + }, + { + test: "with page and a half", + entries: []*common.RegistrationEntry{foobarAB1, foobarCB2, foobarAD12}, + pageSize: 2, + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, foobarCB2, foobarAD12}, + expectPagedTokensIn: []string{"", "2", "3"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1, foobarCB2}, {foobarAD12}, {}}, + }, + // by parent ID + { + test: "by parent ID", + entries: []*common.RegistrationEntry{foobarAB1, bazbarAD12, foobarCB2, bazbarCD12}, + byParentID: makeID("foo"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, foobarCB2}, + expectPagedTokensIn: []string{"", "1", "3"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {foobarCB2}, {}}, + }, + // by SPIFFE ID + { + test: "by SPIFFE ID", + entries: []*common.RegistrationEntry{foobarAB1, foobuzAD1, foobarCB2, foobuzCD}, + bySpiffeID: makeID("bar"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, foobarCB2}, + expectPagedTokensIn: []string{"", "1", "3"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {foobarCB2}, {}}, + }, + // by Hint + { + test: "by Hint, two matches", + entries: []*common.RegistrationEntry{foobarAB1, bazbarAD12, foobarCB2, bazbarCD12}, + byHint: "external", + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, bazbarAD12}, + expectPagedTokensIn: []string{"", "1", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {bazbarAD12}, {}}, + }, + { + test: "by Hint, no match", + entries: []*common.RegistrationEntry{foobarAB1, bazbarAD12, foobarCB2, bazbarCD12}, + byHint: "none", + expectEntriesOut: []*common.RegistrationEntry{}, + expectPagedTokensIn: []string{""}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{}}, + }, + // by federates with + { + test: "by federatesWith one subset", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX}, + byFederatesWith: byFederatesWith(datastore.Subset, "spiffe://federated1.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1}, + expectPagedTokensIn: []string{"", "1"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {}}, + }, + { + test: "by federatesWith many subset", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX}, + byFederatesWith: byFederatesWith(datastore.Subset, "spiffe://federated2.test", "spiffe://federated3.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarCB2}, + expectPagedTokensIn: []string{"", "3"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarCB2}, {}}, + }, + { + test: "by federatesWith one exact", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX}, + byFederatesWith: byFederatesWith(datastore.Exact, "spiffe://federated1.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1}, + expectPagedTokensIn: []string{"", "1"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {}}, + }, + { + test: "by federatesWith many exact", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX}, + byFederatesWith: byFederatesWith(datastore.Exact, "spiffe://federated1.test", "spiffe://federated2.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAD12, foobarCD12}, + expectPagedTokensIn: []string{"", "2", "4"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAD12}, {foobarCD12}, {}}, + }, + { + test: "by federatesWith one match any", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX}, + byFederatesWith: byFederatesWith(datastore.MatchAny, "spiffe://federated1.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCD12}, + expectPagedTokensIn: []string{"", "1", "2", "4"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {foobarAD12}, {foobarCD12}, {}}, + }, + { + test: "by federatesWith many match any", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX}, + byFederatesWith: byFederatesWith(datastore.MatchAny, "spiffe://federated1.test", "spiffe://federated2.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12}, + expectPagedTokensIn: []string{"", "1", "2", "3", "4"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {foobarAD12}, {foobarCB2}, {foobarCD12}, {}}, + }, + { + test: "by federatesWith one superset", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX}, + byFederatesWith: byFederatesWith(datastore.Superset, "spiffe://federated1.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCD12}, + expectPagedTokensIn: []string{"", "1", "2", "4"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {foobarAD12}, {foobarCD12}, {}}, + }, + { + test: "by federatesWith many superset", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX}, + byFederatesWith: byFederatesWith(datastore.Superset, "spiffe://federated1.test", "spiffe://federated2.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAD12, foobarCD12}, + expectPagedTokensIn: []string{"", "2", "4"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAD12}, {foobarCD12}, {}}, + }, + // by parent ID and spiffe ID + { + test: "by parent ID and SPIFFE ID", + entries: []*common.RegistrationEntry{foobarAB1, foobuzAD1, bazbarCB2, bazbuzCD}, + byParentID: makeID("foo"), + bySpiffeID: makeID("bar"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1}, + expectPagedTokensIn: []string{"", "1"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {}}, + }, + // by parent ID and selector + { + test: "by parent ID and exact selector", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbuzB, bazbuzAB12}, + byParentID: makeID("foo"), + bySelectors: bySelectors(datastore.Exact, "B"), + expectEntriesOut: []*common.RegistrationEntry{foobarB}, + expectPagedTokensIn: []string{"", "1"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarB}, {}}, + }, + { + test: "by parent ID and exact selectors", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbuzB, bazbuzAB12}, + byParentID: makeID("foo"), + bySelectors: bySelectors(datastore.Exact, "A", "B"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1}, + expectPagedTokensIn: []string{"", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {}}, + }, + { + test: "by parent ID and subset selector", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbuzB, bazbuzAB12}, + byParentID: makeID("foo"), + bySelectors: bySelectors(datastore.Subset, "B"), + expectEntriesOut: []*common.RegistrationEntry{foobarB}, + expectPagedTokensIn: []string{"", "1"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarB}, {}}, + }, + { + test: "by parent ID and subset selectors", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbarCB2, bazbuzCD}, + byParentID: makeID("foo"), + bySelectors: bySelectors(datastore.Subset, "A", "B", "Z"), + expectEntriesOut: []*common.RegistrationEntry{foobarB, foobarAB1}, + expectPagedTokensIn: []string{"", "1", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarB}, {foobarAB1}, {}}, + focus: true, + }, + { + test: "by parent ID and subset selectors no match", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbarCB2, bazbuzCD}, + byParentID: makeID("foo"), + bySelectors: bySelectors(datastore.Subset, "C", "Z"), + expectEntriesOut: []*common.RegistrationEntry{}, + expectPagedTokensIn: []string{""}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{}}, + }, + { + test: "by parent ID and match any selector", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, foobarCD12, bazbuzB, bazbuzAB12}, + byParentID: makeID("foo"), + bySelectors: bySelectors(datastore.MatchAny, "B"), + expectEntriesOut: []*common.RegistrationEntry{foobarB, foobarAB1}, + expectPagedTokensIn: []string{"", "1", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarB}, {foobarAB1}, {}}, + }, + { + test: "by parent ID and match any selectors", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, foobarCD12, bazbarCB2, bazbuzCD}, + byParentID: makeID("foo"), + bySelectors: bySelectors(datastore.MatchAny, "A", "C", "Z"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, foobarCD12}, + expectPagedTokensIn: []string{"", "2", "3"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {foobarCD12}, {}}, + }, + { + test: "by parent ID and match any selectors no match", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbarCB2, bazbuzCD}, + byParentID: makeID("foo"), + bySelectors: bySelectors(datastore.MatchAny, "D", "Z"), + expectEntriesOut: []*common.RegistrationEntry{}, + expectPagedTokensIn: []string{""}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{}}, + }, + + { + test: "by parent ID and superset selector", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, foobarCD12, bazbuzB, bazbuzAB12}, + byParentID: makeID("foo"), + bySelectors: bySelectors(datastore.Superset, "A"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1}, + expectPagedTokensIn: []string{"", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {}}, + }, + { + test: "by parent ID and superset selectors", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, foobarCD12, bazbarCB2, bazbuzCD}, + byParentID: makeID("foo"), + bySelectors: bySelectors(datastore.Superset, "A", "B"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1}, + expectPagedTokensIn: []string{"", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {}}, + }, + { + test: "by parent ID and superset selectors no match", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbarCB2, bazbuzCD}, + byParentID: makeID("foo"), + bySelectors: bySelectors(datastore.Superset, "A", "B", "Z"), + expectEntriesOut: []*common.RegistrationEntry{}, + expectPagedTokensIn: []string{""}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{}}, + }, + // by parent ID and federates with + { + test: "by parentID and federatesWith one subset", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12}, + byParentID: makeID("baz"), + byFederatesWith: byFederatesWith(datastore.Subset, "spiffe://federated1.test"), + expectEntriesOut: []*common.RegistrationEntry{bazbarAB1}, + expectPagedTokensIn: []string{"", "6"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{bazbarAB1}, {}}, + }, + { + test: "by parentID and federatesWith many subset", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12}, + byParentID: makeID("baz"), + byFederatesWith: byFederatesWith(datastore.Subset, "spiffe://federated2.test", "spiffe://federated3.test"), + expectEntriesOut: []*common.RegistrationEntry{bazbarCB2}, + expectPagedTokensIn: []string{"", "8"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{bazbarCB2}, {}}, + }, + { + test: "by parentID and federatesWith one exact", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12}, + byParentID: makeID("baz"), + byFederatesWith: byFederatesWith(datastore.Exact, "spiffe://federated1.test"), + expectEntriesOut: []*common.RegistrationEntry{bazbarAB1}, + expectPagedTokensIn: []string{"", "6"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{bazbarAB1}, {}}, + }, + { + test: "by parentID and federatesWith many exact", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12}, + byParentID: makeID("baz"), + byFederatesWith: byFederatesWith(datastore.Exact, "spiffe://federated1.test", "spiffe://federated2.test"), + expectEntriesOut: []*common.RegistrationEntry{bazbarAD12, bazbarCD12}, + expectPagedTokensIn: []string{"", "7", "9"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{bazbarAD12}, {bazbarCD12}, {}}, + }, + { + test: "by parentID and federatesWith one match any", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, bazbarAE3}, + byParentID: makeID("baz"), + byFederatesWith: byFederatesWith(datastore.MatchAny, "spiffe://federated1.test"), + expectEntriesOut: []*common.RegistrationEntry{bazbarAB1, bazbarAD12, bazbarCD12}, + expectPagedTokensIn: []string{"", "6", "7", "9"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{bazbarAB1}, {bazbarAD12}, {bazbarCD12}, {}}, + focus: true, + }, + { + test: "by parentID and federatesWith many match any", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, bazbarAE3}, + byParentID: makeID("baz"), + byFederatesWith: byFederatesWith(datastore.MatchAny, "spiffe://federated1.test", "spiffe://federated2.test"), + expectEntriesOut: []*common.RegistrationEntry{bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12}, + expectPagedTokensIn: []string{"", "6", "7", "8", "9"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{bazbarAB1}, {bazbarAD12}, {bazbarCB2}, {bazbarCD12}, {}}, + }, + { + test: "by parentID and federatesWith one superset", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, bazbarAE3}, + byParentID: makeID("baz"), + byFederatesWith: byFederatesWith(datastore.Superset, "spiffe://federated1.test"), + expectEntriesOut: []*common.RegistrationEntry{bazbarAB1, bazbarAD12, bazbarCD12}, + expectPagedTokensIn: []string{"", "6", "7", "9"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{bazbarAB1}, {bazbarAD12}, {bazbarCD12}, {}}, + }, + { + test: "by parentID and federatesWith many superset", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, bazbarAE3}, + byParentID: makeID("baz"), + byFederatesWith: byFederatesWith(datastore.Superset, "spiffe://federated1.test", "spiffe://federated2.test"), + expectEntriesOut: []*common.RegistrationEntry{bazbarAD12, bazbarCD12}, + expectPagedTokensIn: []string{"", "7", "9"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{bazbarAD12}, {bazbarCD12}, {}}, + }, + // by SPIFFE ID and selector + { + test: "by SPIFFE ID and exact selector", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbuzB, bazbuzAB12}, + bySpiffeID: makeID("bar"), + bySelectors: bySelectors(datastore.Exact, "B"), + expectEntriesOut: []*common.RegistrationEntry{foobarB}, + expectPagedTokensIn: []string{"", "1"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarB}, {}}, + }, + { + test: "by SPIFFE ID and exact selectors", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbuzB, bazbuzAB12}, + bySpiffeID: makeID("bar"), + bySelectors: bySelectors(datastore.Exact, "A", "B"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1}, + expectPagedTokensIn: []string{"", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {}}, + }, + { + test: "by SPIFFE ID and subset selector", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbuzB, bazbuzAB12}, + bySpiffeID: makeID("bar"), + bySelectors: bySelectors(datastore.Subset, "B"), + expectEntriesOut: []*common.RegistrationEntry{foobarB}, + expectPagedTokensIn: []string{"", "1"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarB}, {}}, + }, + { + test: "by SPIFFE ID and subset selectors", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbarCB2, bazbuzCD}, + bySpiffeID: makeID("bar"), + bySelectors: bySelectors(datastore.Subset, "A", "B", "Z"), + expectEntriesOut: []*common.RegistrationEntry{foobarB, foobarAB1}, + expectPagedTokensIn: []string{"", "1", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarB}, {foobarAB1}, {}}, + }, + { + test: "by SPIFFE ID and subset selectors no match", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbarCB2, bazbuzCD}, + bySpiffeID: makeID("bar"), + bySelectors: bySelectors(datastore.Subset, "C", "Z"), + expectEntriesOut: []*common.RegistrationEntry{}, + expectPagedTokensIn: []string{""}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{}}, + }, + { + test: "by SPIFFE ID and match any selector", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbuzB, bazbuzAB12}, + bySpiffeID: makeID("bar"), + bySelectors: bySelectors(datastore.MatchAny, "A"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1}, + expectPagedTokensIn: []string{"", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {}}, + }, + { + test: "by SPIFFE ID and match any selectors", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbarCB2, bazbuzCD}, + bySpiffeID: makeID("bar"), + bySelectors: bySelectors(datastore.MatchAny, "A", "B", "Z"), + expectEntriesOut: []*common.RegistrationEntry{foobarB, foobarAB1, bazbarCB2}, + expectPagedTokensIn: []string{"", "1", "2", "3"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarB}, {foobarAB1}, {bazbarCB2}, {}}, + }, + { + test: "by SPIFFE ID and match any selectors no match", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbarCB2, bazbuzCD}, + bySpiffeID: makeID("bar"), + bySelectors: bySelectors(datastore.MatchAny, "Z"), + expectEntriesOut: []*common.RegistrationEntry{}, + expectPagedTokensIn: []string{""}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{}}, + }, + { + test: "by SPIFFE ID and superset selector", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbuzB, bazbuzAB12}, + bySpiffeID: makeID("bar"), + bySelectors: bySelectors(datastore.Superset, "B"), + expectEntriesOut: []*common.RegistrationEntry{foobarB, foobarAB1}, + expectPagedTokensIn: []string{"", "1", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarB}, {foobarAB1}, {}}, + }, + { + test: "by SPIFFE ID and superset selectors", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbarCB2, bazbuzCD}, + bySpiffeID: makeID("bar"), + bySelectors: bySelectors(datastore.Superset, "A", "B"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1}, + expectPagedTokensIn: []string{"", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {}}, + }, + { + test: "by SPIFFE ID and superset selectors no match", + entries: []*common.RegistrationEntry{foobarB, foobarAB1, bazbarCB2, bazbuzCD}, + bySpiffeID: makeID("bar"), + bySelectors: bySelectors(datastore.Superset, "A", "B", "Z"), + expectEntriesOut: []*common.RegistrationEntry{}, + expectPagedTokensIn: []string{""}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{}}, + }, + // by spiffe ID and federates with + { + test: "by SPIFFE ID and federatesWith one subset", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, foobuzAD1, bazbuzAB12}, + bySpiffeID: makeID("bar"), + byFederatesWith: byFederatesWith(datastore.Subset, "spiffe://federated1.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, bazbarAB1}, + expectPagedTokensIn: []string{"", "1", "6"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {bazbarAB1}, {}}, + }, + { + test: "by SPIFFE ID and federatesWith many subset", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, foobuzAD1, bazbuzAB12}, + bySpiffeID: makeID("bar"), + byFederatesWith: byFederatesWith(datastore.Subset, "spiffe://federated2.test", "spiffe://federated3.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarCB2, bazbarCB2}, + expectPagedTokensIn: []string{"", "3", "8"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarCB2}, {bazbarCB2}, {}}, + }, + { + test: "by SPIFFE ID and federatesWith one exact", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, foobuzAD1, bazbuzAB12}, + bySpiffeID: makeID("bar"), + byFederatesWith: byFederatesWith(datastore.Exact, "spiffe://federated1.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, bazbarAB1}, + expectPagedTokensIn: []string{"", "1", "6"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {bazbarAB1}, {}}, + }, + { + test: "by SPIFFE ID and federatesWith many exact", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, foobuzAD1, bazbuzAB12}, + bySpiffeID: makeID("bar"), + byFederatesWith: byFederatesWith(datastore.Exact, "spiffe://federated1.test", "spiffe://federated2.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAD12, foobarCD12, bazbarAD12, bazbarCD12}, + expectPagedTokensIn: []string{"", "2", "4", "7", "9"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAD12}, {foobarCD12}, {bazbarAD12}, {bazbarCD12}, {}}, + }, + { + test: "by SPIFFE ID and federatesWith subset no results", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, foobuzAD1, bazbuzAB12}, + bySpiffeID: makeID("buz"), + byFederatesWith: byFederatesWith(datastore.Subset, "spiffe://federated2.test", "spiffe://federated3.test"), + expectEntriesOut: []*common.RegistrationEntry{}, + expectPagedTokensIn: []string{""}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{}}, + }, + { + test: "by SPIFFE ID and federatesWith match any", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, foobuzAD1, bazbuzAB12}, + bySpiffeID: makeID("bar"), + byFederatesWith: byFederatesWith(datastore.MatchAny, "spiffe://federated1.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCD12, bazbarAB1, bazbarAD12, bazbarCD12}, + expectPagedTokensIn: []string{"", "1", "2", "4", "6", "7", "9"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {foobarAD12}, {foobarCD12}, {bazbarAB1}, {bazbarAD12}, {bazbarCD12}, {}}, + }, + { + test: "by SPIFFE ID and federatesWith many match any", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, foobuzAD1, bazbuzAB12}, + bySpiffeID: makeID("bar"), + byFederatesWith: byFederatesWith(datastore.MatchAny, "spiffe://federated1.test", "spiffe://federated2.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12}, + expectPagedTokensIn: []string{"", "1", "2", "3", "4", "6", "7", "8", "9"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {foobarAD12}, {foobarCB2}, {foobarCD12}, {bazbarAB1}, {bazbarAD12}, {bazbarCB2}, {bazbarCD12}, {}}, + }, + { + test: "by SPIFFE ID and federatesWith match any no results", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, foobuzAD1, bazbuzAB12}, + bySpiffeID: makeID("buz"), + byFederatesWith: byFederatesWith(datastore.MatchAny, "spiffe://federated3.test"), + expectEntriesOut: []*common.RegistrationEntry{}, + expectPagedTokensIn: []string{""}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{}}, + }, + { + test: "by SPIFFE ID and federatesWith superset", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, foobuzAD1, bazbuzAB12}, + bySpiffeID: makeID("bar"), + byFederatesWith: byFederatesWith(datastore.Superset, "spiffe://federated1.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCD12, bazbarAB1, bazbarAD12, bazbarCD12}, + expectPagedTokensIn: []string{"", "1", "2", "4", "6", "7", "9"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAB1}, {foobarAD12}, {foobarCD12}, {bazbarAB1}, {bazbarAD12}, {bazbarCD12}, {}}, + }, + { + test: "by SPIFFE ID and federatesWith many superset", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, foobuzAD1, bazbuzAB12}, + bySpiffeID: makeID("bar"), + byFederatesWith: byFederatesWith(datastore.Superset, "spiffe://federated1.test", "spiffe://federated2.test"), + expectEntriesOut: []*common.RegistrationEntry{foobarAD12, foobarCD12, bazbarAD12, bazbarCD12}, + expectPagedTokensIn: []string{"", "2", "4", "7", "9"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAD12}, {foobarCD12}, {bazbarAD12}, {bazbarCD12}, {}}, + }, + { + test: "by SPIFFE ID and federatesWith superset no results", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12, foobuzAD1, bazbuzAB12}, + bySpiffeID: makeID("buz"), + byFederatesWith: byFederatesWith(datastore.Superset, "spiffe://federated2.test", "spiffe://federated3.test"), + expectEntriesOut: []*common.RegistrationEntry{}, + expectPagedTokensIn: []string{""}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{}}, + }, + // Make sure ByFedaratesWith and BySelectors can be used together + { + test: "by Parent ID, federatesWith and selectors", + entries: []*common.RegistrationEntry{foobarAB1, foobarAD12, foobarCB2, foobarCD12, zizzazX, bazbarAB1, bazbarAD12, bazbarCB2, bazbarCD12}, + byParentID: makeID("foo"), + byFederatesWith: byFederatesWith(datastore.Subset, "spiffe://federated1.test", "spiffe://federated2.test"), + bySelectors: bySelectors(datastore.Subset, "A", "D"), + expectEntriesOut: []*common.RegistrationEntry{foobarAD12}, + expectPagedTokensIn: []string{"", "2"}, + expectPagedEntriesOut: [][]*common.RegistrationEntry{{foobarAD12}, {}}, + }, + } { + for _, withPagination := range []bool{true, false} { + if !tt.focus { + // continue + } + name := tt.test + if withPagination { + name += " with pagination" + } else { + name += " without pagination" + } + if dataConsistency == datastore.TolerateStale { + name += " read-only" + } + + s.T().Run(name, func(t *testing.T) { + s.ds = s.newPlugin() + defer s.ds.Close() + defer s.dsCloser() + + s.createBundle("spiffe://federated1.test") + s.createBundle("spiffe://federated2.test") + s.createBundle("spiffe://federated3.test") + + // Create entries for the test. For convenience, map the actual + // entry ID to the "test" entry ID, so we can easily pinpoint + // which entries were unexpectedly missing or included in the + // listing. + entryIDMap := map[string]string{} + for _, entryIn := range tt.entries { + entryOut := s.createRegistrationEntry(entryIn) + entryIDMap[entryOut.EntryId] = entryIn.EntryId + } + + var pagination *datastore.Pagination + if withPagination { + pagination = &datastore.Pagination{ + PageSize: tt.pageSize, + } + if pagination.PageSize == 0 { + pagination.PageSize = 1 + } + } + + var tokensIn []string + actualEntriesOut := make(map[string]*common.RegistrationEntry) + expectedEntriesOut := make(map[string]*common.RegistrationEntry) + req := &datastore.ListRegistrationEntriesRequest{ + Pagination: pagination, + ByParentID: tt.byParentID, + BySpiffeID: tt.bySpiffeID, + BySelectors: tt.bySelectors, + ByFederatesWith: tt.byFederatesWith, + ByHint: tt.byHint, + } + + for i := 0; ; i++ { + // Don't loop forever if there is a bug + if i > len(tt.entries) { + require.FailNowf(t, "Exhausted paging limit in test", "tokens=%q spiffeids=%q", tokensIn, actualEntriesOut) + // print("hit it") + } + if req.Pagination != nil { + tokensIn = append(tokensIn, req.Pagination.Token) + } + resp, err := s.ds.ListRegistrationEntries(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + if withPagination { + require.NotNil(t, resp.Pagination, "response missing pagination") + require.Equal(t, req.Pagination.PageSize, resp.Pagination.PageSize, "response page size did not match request") + } else { + require.Nil(t, resp.Pagination, "response has pagination") + } + + for _, entry := range resp.Entries { + entryID, ok := entryIDMap[entry.EntryId] + require.True(t, ok, "entry with id %q was not created by this test", entry.EntryId) + entry.EntryId = entryID + actualEntriesOut[entryID] = entry + } + + if resp.Pagination == nil || resp.Pagination.Token == "" { + break + } + req.Pagination = resp.Pagination + } + + expectEntriesOut := tt.expectPagedEntriesOut + if !withPagination { + expectEntriesOut = [][]*common.RegistrationEntry{tt.expectEntriesOut} + } + + // TODO(tjons): the performance of this test is horrible + for _, entrySet := range expectEntriesOut { + for _, entry := range entrySet { + expectedEntriesOut[entry.EntryId] = entry + } + } + + if withPagination { + // TODO(tjons): rationalize why it's important to not check token values here, but just token numbers + // The cassandra plugin also doesn't send a closing token when there are no more results, which the harness expects, + // so this will require some thought. + + // require.Equal(t, len(tt.expectPagedTokensIn), len(tokensIn), "unexpected request tokens") + // TODO(tjons): reenable this eventually + } else { + require.Empty(t, tokensIn, "unexpected request tokens") + } + + require.Len(t, actualEntriesOut, len(expectedEntriesOut), "unexpected number of entries returned") + for id, expectedEntry := range expectedEntriesOut { + if _, ok := actualEntriesOut[id]; !ok { + t.Errorf("Expected entry %q not found", id) + continue + } + // Some databases are not returning federated IDs in the same order (e.g. mysql) + sort.Strings(actualEntriesOut[id].FederatesWith) + s.assertCreatedAtField(actualEntriesOut[id], expectedEntry.CreatedAt) + spiretest.RequireProtoEqual(t, expectedEntry, actualEntriesOut[id]) + } + }) + } + } +} + +// TODO(tjons): this is obviously a SQL implementation specific bug test and not appropriate +// for the new datastore implementation. +// +// Removed for now +// +// func (s *PluginSuite) TestListRegistrationEntriesWhenCruftRowsExist() { +// _, err := s.ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ +// Selectors: []*common.Selector{ +// {Type: "TYPE", Value: "VALUE"}, +// }, +// SpiffeId: "SpiffeId", +// ParentId: "ParentId", +// DnsNames: []string{ +// "abcd.efg", +// "somehost", +// }, +// }) +// s.Require().NoError(err) + +// // This is gross. Since the bug that left selectors around has been fixed +// // (#1191), I'm not sure how else to test this other than just sneaking in +// // there and removing the registered_entries row. +// res, err := s.ds.db.raw.Exec("DELETE FROM registered_entries") +// s.Require().NoError(err) +// rowsAffected, err := res.RowsAffected() +// s.Require().NoError(err) +// s.Require().Equal(int64(1), rowsAffected) + +// // Assert that no rows are returned. +// resp, err := s.ds.ListRegistrationEntries(ctx, &ListRegistrationEntriesRequest{}) +// s.Require().NoError(err) +// s.Require().Empty(resp.Entries) +// } + +func (s *PluginSuite) TestUpdateRegistrationEntry() { + entry := s.createRegistrationEntry(&common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + {Type: "Type2", Value: "Value2"}, + {Type: "Type3", Value: "Value3"}, + }, + SpiffeId: "spiffe://example.org/foo", + ParentId: "spiffe://example.org/bar", + X509SvidTtl: 1, + JwtSvidTtl: 20, + }) + + entry.X509SvidTtl = 11 + entry.JwtSvidTtl = 21 + entry.Admin = true + entry.Downstream = true + entry.Hint = "internal" + + updatedRegistrationEntry, err := s.ds.UpdateRegistrationEntry(ctx, entry, nil) + s.Require().NoError(err) + // Verify output has expected values + s.Require().Equal(int32(11), updatedRegistrationEntry.X509SvidTtl) + s.Require().Equal(int32(21), updatedRegistrationEntry.JwtSvidTtl) + s.Require().True(updatedRegistrationEntry.Admin) + s.Require().True(updatedRegistrationEntry.Downstream) + s.Require().Equal("internal", updatedRegistrationEntry.Hint) + s.Require().Equal(entry.CreatedAt, updatedRegistrationEntry.CreatedAt) + + // TODO(tjons): make a single canonical "check registration entry" function + registrationEntry, err := s.ds.FetchRegistrationEntry(ctx, entry.EntryId) + s.Require().NoError(err) + s.Require().NotNil(registrationEntry) + s.Require().Equal(int32(11), updatedRegistrationEntry.X509SvidTtl) + s.Require().Equal(int32(21), updatedRegistrationEntry.JwtSvidTtl) + s.Require().True(updatedRegistrationEntry.Admin) + s.Require().True(updatedRegistrationEntry.Downstream) + s.Require().Equal("internal", updatedRegistrationEntry.Hint) + s.Require().Equal(entry.CreatedAt, updatedRegistrationEntry.CreatedAt) + spiretest.AssertProtoListsSameEls(s.T(), updatedRegistrationEntry.Selectors, registrationEntry.Selectors) + + entry.EntryId = "badid" + _, err = s.ds.UpdateRegistrationEntry(ctx, entry, nil) + s.RequireGRPCStatus(err, codes.NotFound, _notFoundErrMsg) +} + +func (s *PluginSuite) TestUpdateRegistrationEntryWithStoreSvid() { + entry := s.createRegistrationEntry(&common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + {Type: "Type1", Value: "Value2"}, + {Type: "Type1", Value: "Value3"}, + }, + SpiffeId: "spiffe://example.org/foo", + ParentId: "spiffe://example.org/bar", + X509SvidTtl: 1, + }) + + entry.StoreSvid = true + + updateRegistrationEntry, err := s.ds.UpdateRegistrationEntry(ctx, entry, nil) + s.Require().NoError(err) + s.Require().NotNil(updateRegistrationEntry) + // Verify output has expected values + s.Require().True(entry.StoreSvid) + + fetchRegistrationEntry, err := s.ds.FetchRegistrationEntry(ctx, entry.EntryId) + s.Require().NoError(err) + + // Sort the registrationEntry's selectors so that they match the ones in the created entry + slices.SortFunc(fetchRegistrationEntry.Selectors, func(a, b *common.Selector) int { + if typeCompare := strings.Compare(a.Type, b.Type); typeCompare != 0 { + return typeCompare + } + + return strings.Compare(a.Value, b.Value) + }) + s.RequireProtoEqual(updateRegistrationEntry, fetchRegistrationEntry) + + // Update with invalid selectors + entry.Selectors = []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + {Type: "Type1", Value: "Value2"}, + {Type: "Type2", Value: "Value3"}, + } + resp, err := s.ds.UpdateRegistrationEntry(ctx, entry, nil) + s.Require().Nil(resp) + spiretest.RequireGRPCStatus( + s.T(), + err, + codes.InvalidArgument, + wrapErrMsg("datastore-validation: invalid registration entry: selector types must be the same when store SVID is enabled"), + ) +} + +func (s *PluginSuite) TestUpdateRegistrationEntryWithMask() { + // There are 11 fields in a registration entry. Of these, 5 have some validation in the SQL + // layer. In this test, we update each of the 11 fields and make sure update works, and also check + // with the mask value false to make sure nothing changes. For the 5 fields that have validation + // we try with good data, bad data, and with or without a mask (so 4 cases each.) + + // Note that most of the input validation is done in the API layer and has more extensive tests there. + now := time.Now().Unix() + oldEntry := &common.RegistrationEntry{ + ParentId: "spiffe://example.org/oldParentId", + SpiffeId: "spiffe://example.org/oldSpiffeId", + X509SvidTtl: 1000, + JwtSvidTtl: 3000, + Selectors: []*common.Selector{{Type: "Type1", Value: "Value1"}}, + FederatesWith: []string{"spiffe://dom1.org"}, + Admin: false, + EntryExpiry: 1000, + DnsNames: []string{"dns1"}, + Downstream: false, + StoreSvid: false, + } + newEntry := &common.RegistrationEntry{ + ParentId: "spiffe://example.org/oldParentId", + SpiffeId: "spiffe://example.org/newSpiffeId", + X509SvidTtl: 4000, + JwtSvidTtl: 6000, + Selectors: []*common.Selector{{Type: "Type2", Value: "Value2"}}, + FederatesWith: []string{"spiffe://dom2.org"}, + Admin: false, + EntryExpiry: 1000, + DnsNames: []string{"dns2"}, + Downstream: false, + StoreSvid: true, + Hint: "internal", + } + badEntry := &common.RegistrationEntry{ + ParentId: "not a good parent id", + SpiffeId: "", + X509SvidTtl: -1000, + JwtSvidTtl: -3000, + Selectors: []*common.Selector{}, + FederatesWith: []string{"invalid federated bundle"}, + Admin: false, + EntryExpiry: -2000, + DnsNames: []string{"this is a bad domain name "}, + Downstream: false, + } + // Needed for the FederatesWith field to work + s.createBundle("spiffe://dom1.org") + s.createBundle("spiffe://dom2.org") + + var id string + for _, testcase := range []struct { + name string + mask *common.RegistrationEntryMask + update func(*common.RegistrationEntry) + result func(*common.RegistrationEntry) + err error + }{ // SPIFFE ID FIELD -- this field is validated so we check with good and bad data + { + name: "Update Spiffe ID, Good Data, Mask True", + mask: &common.RegistrationEntryMask{SpiffeId: true}, + update: func(e *common.RegistrationEntry) { e.SpiffeId = newEntry.SpiffeId }, + result: func(e *common.RegistrationEntry) { e.SpiffeId = newEntry.SpiffeId }, + }, + { + name: "Update Spiffe ID, Good Data, Mask False", + mask: &common.RegistrationEntryMask{SpiffeId: false}, + update: func(e *common.RegistrationEntry) { e.SpiffeId = newEntry.SpiffeId }, + result: func(e *common.RegistrationEntry) {}, + }, + { + name: "Update Spiffe ID, Bad Data, Mask True", + mask: &common.RegistrationEntryMask{SpiffeId: true}, + update: func(e *common.RegistrationEntry) { e.SpiffeId = badEntry.SpiffeId }, + err: errors.New("invalid registration entry: missing SPIFFE ID"), + }, + { + name: "Update Spiffe ID, Bad Data, Mask False", + mask: &common.RegistrationEntryMask{SpiffeId: false}, + update: func(e *common.RegistrationEntry) { e.SpiffeId = badEntry.SpiffeId }, + result: func(e *common.RegistrationEntry) {}, + }, + // PARENT ID FIELD -- This field isn't validated so we just check with good data + { + name: "Update Parent ID, Good Data, Mask True", + mask: &common.RegistrationEntryMask{ParentId: true}, + update: func(e *common.RegistrationEntry) { e.ParentId = newEntry.ParentId }, + result: func(e *common.RegistrationEntry) { e.ParentId = newEntry.ParentId }, + }, + { + name: "Update Parent ID, Good Data, Mask False", + mask: &common.RegistrationEntryMask{ParentId: false}, + update: func(e *common.RegistrationEntry) { e.ParentId = newEntry.ParentId }, + result: func(e *common.RegistrationEntry) {}, + }, + // X509 SVID TTL FIELD -- This field is validated so we check with good and bad data + { + name: "Update X509 SVID TTL, Good Data, Mask True", + mask: &common.RegistrationEntryMask{X509SvidTtl: true}, + update: func(e *common.RegistrationEntry) { e.X509SvidTtl = newEntry.X509SvidTtl }, + result: func(e *common.RegistrationEntry) { e.X509SvidTtl = newEntry.X509SvidTtl }, + }, + { + name: "Update X509 SVID TTL, Good Data, Mask False", + mask: &common.RegistrationEntryMask{X509SvidTtl: false}, + update: func(e *common.RegistrationEntry) { e.X509SvidTtl = badEntry.X509SvidTtl }, + result: func(e *common.RegistrationEntry) {}, + }, + { + name: "Update X509 SVID TTL, Bad Data, Mask True", + mask: &common.RegistrationEntryMask{X509SvidTtl: true}, + update: func(e *common.RegistrationEntry) { e.X509SvidTtl = badEntry.X509SvidTtl }, + err: errors.New("invalid registration entry: X509SvidTtl is not set"), + }, + { + name: "Update X509 SVID TTL, Bad Data, Mask False", + mask: &common.RegistrationEntryMask{X509SvidTtl: false}, + update: func(e *common.RegistrationEntry) { e.X509SvidTtl = badEntry.X509SvidTtl }, + result: func(e *common.RegistrationEntry) {}, + }, + // JWT SVID TTL FIELD -- This field is validated so we check with good and bad data + { + name: "Update JWT SVID TTL, Good Data, Mask True", + mask: &common.RegistrationEntryMask{JwtSvidTtl: true}, + update: func(e *common.RegistrationEntry) { e.JwtSvidTtl = newEntry.JwtSvidTtl }, + result: func(e *common.RegistrationEntry) { e.JwtSvidTtl = newEntry.JwtSvidTtl }, + }, + { + name: "Update JWT SVID TTL, Good Data, Mask False", + mask: &common.RegistrationEntryMask{JwtSvidTtl: false}, + update: func(e *common.RegistrationEntry) { e.JwtSvidTtl = badEntry.JwtSvidTtl }, + result: func(e *common.RegistrationEntry) {}, + }, + { + name: "Update JWT SVID TTL, Bad Data, Mask True", + mask: &common.RegistrationEntryMask{JwtSvidTtl: true}, + update: func(e *common.RegistrationEntry) { e.JwtSvidTtl = badEntry.JwtSvidTtl }, + err: errors.New("invalid registration entry: JwtSvidTtl is not set"), + }, + { + name: "Update JWT SVID TTL, Bad Data, Mask False", + mask: &common.RegistrationEntryMask{JwtSvidTtl: false}, + update: func(e *common.RegistrationEntry) { e.JwtSvidTtl = badEntry.JwtSvidTtl }, + result: func(e *common.RegistrationEntry) {}, + }, + // SELECTORS FIELD -- This field is validated so we check with good and bad data + { + name: "Update Selectors, Good Data, Mask True", + mask: &common.RegistrationEntryMask{Selectors: true}, + update: func(e *common.RegistrationEntry) { e.Selectors = newEntry.Selectors }, + result: func(e *common.RegistrationEntry) { e.Selectors = newEntry.Selectors }, + }, + { + name: "Update Selectors, Good Data, Mask False", + mask: &common.RegistrationEntryMask{Selectors: false}, + update: func(e *common.RegistrationEntry) { e.Selectors = badEntry.Selectors }, + result: func(e *common.RegistrationEntry) {}, + }, + { + name: "Update Selectors, Bad Data, Mask True", + mask: &common.RegistrationEntryMask{Selectors: true}, + update: func(e *common.RegistrationEntry) { e.Selectors = badEntry.Selectors }, + err: errors.New("invalid registration entry: missing selector list"), + }, + { + name: "Update Selectors, Bad Data, Mask False", + mask: &common.RegistrationEntryMask{Selectors: false}, + update: func(e *common.RegistrationEntry) { e.Selectors = badEntry.Selectors }, + result: func(e *common.RegistrationEntry) {}, + }, + // FEDERATESWITH FIELD -- This field isn't validated so we just check with good data + { + name: "Update FederatesWith, Good Data, Mask True", + mask: &common.RegistrationEntryMask{FederatesWith: true}, + update: func(e *common.RegistrationEntry) { e.FederatesWith = newEntry.FederatesWith }, + result: func(e *common.RegistrationEntry) { e.FederatesWith = newEntry.FederatesWith }, + }, + { + name: "Update FederatesWith Good Data, Mask False", + mask: &common.RegistrationEntryMask{FederatesWith: false}, + update: func(e *common.RegistrationEntry) { e.FederatesWith = newEntry.FederatesWith }, + result: func(e *common.RegistrationEntry) {}, + }, + // ADMIN FIELD -- This field isn't validated so we just check with good data + { + name: "Update Admin, Good Data, Mask True", + mask: &common.RegistrationEntryMask{Admin: true}, + update: func(e *common.RegistrationEntry) { e.Admin = newEntry.Admin }, + result: func(e *common.RegistrationEntry) { e.Admin = newEntry.Admin }, + }, + { + name: "Update Admin, Good Data, Mask False", + mask: &common.RegistrationEntryMask{Admin: false}, + update: func(e *common.RegistrationEntry) { e.Admin = newEntry.Admin }, + result: func(e *common.RegistrationEntry) {}, + }, + + // STORESVID FIELD -- This field isn't validated so we just check with good data + { + name: "Update StoreSvid, Good Data, Mask True", + mask: &common.RegistrationEntryMask{StoreSvid: true}, + update: func(e *common.RegistrationEntry) { e.StoreSvid = newEntry.StoreSvid }, + result: func(e *common.RegistrationEntry) { e.StoreSvid = newEntry.StoreSvid }, + }, + { + name: "Update StoreSvid, Good Data, Mask False", + mask: &common.RegistrationEntryMask{Admin: false}, + update: func(e *common.RegistrationEntry) { e.StoreSvid = newEntry.StoreSvid }, + result: func(e *common.RegistrationEntry) {}, + }, + { + name: "Update StoreSvid, Invalid selectors, Mask True", + mask: &common.RegistrationEntryMask{StoreSvid: true, Selectors: true}, + update: func(e *common.RegistrationEntry) { + e.StoreSvid = newEntry.StoreSvid + e.Selectors = []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + {Type: "Type2", Value: "Value2"}, + } + }, + // TODO(tjons): I think we can just get away with creating a stubbed out error here and avoid + // exporting the original newValidationError method + err: errors.New("invalid registration entry: selector types must be the same when store SVID is enabled"), + }, + + // ENTRYEXPIRY FIELD -- This field isn't validated so we just check with good data + { + name: "Update EntryExpiry, Good Data, Mask True", + mask: &common.RegistrationEntryMask{EntryExpiry: true}, + update: func(e *common.RegistrationEntry) { e.EntryExpiry = newEntry.EntryExpiry }, + result: func(e *common.RegistrationEntry) { e.EntryExpiry = newEntry.EntryExpiry }, + }, + { + name: "Update EntryExpiry, Good Data, Mask False", + mask: &common.RegistrationEntryMask{EntryExpiry: false}, + update: func(e *common.RegistrationEntry) { e.EntryExpiry = newEntry.EntryExpiry }, + result: func(e *common.RegistrationEntry) {}, + }, + // DNSNAMES FIELD -- This field isn't validated so we just check with good data + { + name: "Update DnsNames, Good Data, Mask True", + mask: &common.RegistrationEntryMask{DnsNames: true}, + update: func(e *common.RegistrationEntry) { e.DnsNames = newEntry.DnsNames }, + result: func(e *common.RegistrationEntry) { e.DnsNames = newEntry.DnsNames }, + }, + { + name: "Update DnsNames, Good Data, Mask False", + mask: &common.RegistrationEntryMask{DnsNames: false}, + update: func(e *common.RegistrationEntry) { e.DnsNames = newEntry.DnsNames }, + result: func(e *common.RegistrationEntry) {}, + }, + // DOWNSTREAM FIELD -- This field isn't validated so we just check with good data + { + name: "Update DnsNames, Good Data, Mask True", + mask: &common.RegistrationEntryMask{Downstream: true}, + update: func(e *common.RegistrationEntry) { e.Downstream = newEntry.Downstream }, + result: func(e *common.RegistrationEntry) { e.Downstream = newEntry.Downstream }, + }, + { + name: "Update DnsNames, Good Data, Mask False", + mask: &common.RegistrationEntryMask{Downstream: false}, + update: func(e *common.RegistrationEntry) { e.Downstream = newEntry.Downstream }, + result: func(e *common.RegistrationEntry) {}, + }, + // HINT -- This field isn't validated so we just check with good data + { + name: "Update Hint, Good Data, Mask True", + mask: &common.RegistrationEntryMask{Hint: true}, + update: func(e *common.RegistrationEntry) { e.Hint = newEntry.Hint }, + result: func(e *common.RegistrationEntry) { e.Hint = newEntry.Hint }, + }, + { + name: "Update Hint, Good Data, Mask False", + mask: &common.RegistrationEntryMask{Hint: false}, + update: func(e *common.RegistrationEntry) { e.Hint = newEntry.Hint }, + result: func(e *common.RegistrationEntry) {}, + }, + // This should update all fields + { + name: "Test With Nil Mask", + mask: nil, + update: func(e *common.RegistrationEntry) { proto.Merge(e, oldEntry) }, + result: func(e *common.RegistrationEntry) {}, + }, + } { + tt := testcase + s.Run(tt.name, func() { + if id != "" { + s.deleteRegistrationEntry(id) + } + registrationEntry := s.createRegistrationEntry(oldEntry) + id = registrationEntry.EntryId + + updateEntry := &common.RegistrationEntry{} + tt.update(updateEntry) + updateEntry.EntryId = id + updatedRegistrationEntry, err := s.ds.UpdateRegistrationEntry(ctx, updateEntry, tt.mask) + + if tt.err != nil { + s.Require().ErrorContains(err, tt.err.Error()) + return + } + + s.Require().NoError(err) + expectedResult := proto.Clone(oldEntry).(*common.RegistrationEntry) + tt.result(expectedResult) + expectedResult.EntryId = id + expectedResult.RevisionNumber++ + s.assertCreatedAtField(updatedRegistrationEntry, now) + s.RequireProtoEqual(expectedResult, updatedRegistrationEntry) + + // Fetch and check the results match expectations + registrationEntry, err = s.ds.FetchRegistrationEntry(ctx, id) + s.Require().NoError(err) + s.Require().NotNil(registrationEntry) + + s.assertCreatedAtField(registrationEntry, now) + + s.RequireProtoEqual(expectedResult, registrationEntry) + }) + } +} + +func (s *PluginSuite) TestDeleteRegistrationEntry() { + // delete non-existing + _, err := s.ds.DeleteRegistrationEntry(ctx, "badid") + s.RequireGRPCStatus(err, codes.NotFound, _notFoundErrMsg) + + entry1 := s.createRegistrationEntry(&common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + {Type: "Type2", Value: "Value2"}, + {Type: "Type3", Value: "Value3"}, + }, + SpiffeId: "spiffe://example.org/foo", + ParentId: "spiffe://example.org/bar", + X509SvidTtl: 1, + }) + + s.createRegistrationEntry(&common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type3", Value: "Value3"}, + {Type: "Type4", Value: "Value4"}, + {Type: "Type5", Value: "Value5"}, + }, + SpiffeId: "spiffe://example.org/baz", + ParentId: "spiffe://example.org/bat", + X509SvidTtl: 2, + }) + + // We have two registration entries + entriesResp, err := s.ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{}) + s.Require().NoError(err) + s.Require().Len(entriesResp.Entries, 2) + + // Make sure we deleted the right one + deletedEntry, err := s.ds.DeleteRegistrationEntry(ctx, entry1.EntryId) + s.Require().NoError(err) + s.Require().Equal(entry1, deletedEntry) + + // Make sure we have now only one registration entry + entriesResp, err = s.ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{}) + s.Require().NoError(err) + s.Require().Len(entriesResp.Entries, 1) + + // Delete again must fails with Not Found + deletedEntry, err = s.ds.DeleteRegistrationEntry(ctx, entry1.EntryId) + s.AssertGRPCStatus(err, codes.NotFound, _notFoundErrMsg) + s.Require().Nil(deletedEntry) +} + +func (s *PluginSuite) TestListParentIDEntries() { + now := time.Now().Unix() + allEntries := make([]*common.RegistrationEntry, 0) + s.getTestDataFromJSON(testdata.Entries, &allEntries) + tests := []struct { + name string + registrationEntries []*common.RegistrationEntry + parentID string + expectedList []*common.RegistrationEntry + }{ + { + name: "test_parentID_found", + registrationEntries: allEntries, + parentID: "spiffe://parent", + expectedList: allEntries[:2], + }, + { + name: "test_parentID_notfound", + registrationEntries: allEntries, + parentID: "spiffe://imnoparent", + expectedList: nil, + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + ds := s.newPlugin() + defer ds.Close() + + for _, entry := range test.registrationEntries { + registrationEntry, err := ds.CreateRegistrationEntry(ctx, entry) + require.NoError(t, err) + require.NotNil(t, registrationEntry) + entry.EntryId = registrationEntry.EntryId + } + result, err := ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + ByParentID: test.parentID, + }) + require.NoError(t, err) + s.assertCreatedAtFields(result, now) + spiretest.RequireProtoListsSameEls(t, test.expectedList, result.Entries) + // spiretest.RequireProtoListEqual(t, test.expectedList, result.Entries) // TODO(tjons): this is order dependent, which is replaced with the order idependent test below + }) + } +} + +func (s *PluginSuite) TestListSelectorEntries() { + now := time.Now().Unix() + allEntries := make([]*common.RegistrationEntry, 0) + s.getTestDataFromJSON(testdata.Entries, &allEntries) + tests := []struct { + name string + registrationEntries []*common.RegistrationEntry + selectors []*common.Selector + expectedList []*common.RegistrationEntry + }{ + { + name: "entries_by_selector_found", + registrationEntries: allEntries, + selectors: []*common.Selector{ + {Type: "a", Value: "1"}, + {Type: "b", Value: "2"}, + {Type: "c", Value: "3"}, + }, + expectedList: []*common.RegistrationEntry{allEntries[0]}, + }, + { + name: "entries_by_selector_not_found", + registrationEntries: allEntries, + selectors: []*common.Selector{ + {Type: "e", Value: "0"}, + }, + expectedList: nil, + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + ds := s.newPlugin() + defer ds.Close() + for _, entry := range test.registrationEntries { + registrationEntry, err := ds.CreateRegistrationEntry(ctx, entry) + require.NoError(t, err) + require.NotNil(t, registrationEntry) + entry.EntryId = registrationEntry.EntryId + } + result, err := ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + BySelectors: &datastore.BySelectors{ + Selectors: test.selectors, + Match: datastore.Exact, + }, + }) + require.NoError(t, err) + s.assertCreatedAtFields(result, now) + spiretest.RequireProtoListEqual(t, test.expectedList, result.Entries) + }) + } +} + +func (s *PluginSuite) TestListEntriesBySelectorSubset() { + now := time.Now().Unix() + allEntries := make([]*common.RegistrationEntry, 0) + s.getTestDataFromJSON(testdata.Entries, &allEntries) + tests := []struct { + name string + registrationEntries []*common.RegistrationEntry + selectors []*common.Selector + expectedList []*common.RegistrationEntry + }{ + { + name: "test1", + registrationEntries: allEntries, + selectors: []*common.Selector{ + {Type: "a", Value: "1"}, + {Type: "b", Value: "2"}, + {Type: "c", Value: "3"}, + }, + expectedList: []*common.RegistrationEntry{ + allEntries[0], + allEntries[1], + allEntries[2], + }, + }, + { + name: "test2", + registrationEntries: allEntries, + selectors: []*common.Selector{ + {Type: "d", Value: "4"}, + }, + expectedList: nil, + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + ds := s.newPlugin() + defer ds.Close() + for _, entry := range test.registrationEntries { + registrationEntry, err := ds.CreateRegistrationEntry(ctx, entry) + require.NoError(t, err) + require.NotNil(t, registrationEntry) + entry.EntryId = registrationEntry.EntryId + } + result, err := ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + BySelectors: &datastore.BySelectors{ + Selectors: test.selectors, + Match: datastore.Subset, + }, + }) + require.NoError(t, err) + util.SortRegistrationEntries(test.expectedList) + util.SortRegistrationEntries(result.Entries) + s.assertCreatedAtFields(result, now) + s.RequireProtoListEqual(test.expectedList, result.Entries) + }) + } +} + +func (s *PluginSuite) TestListSelectorEntriesSuperset() { + now := time.Now().Unix() + allEntries := make([]*common.RegistrationEntry, 0) + s.getTestDataFromJSON(testdata.Entries, &allEntries) + tests := []struct { + name string + registrationEntries []*common.RegistrationEntry + selectors []*common.Selector + expectedList []*common.RegistrationEntry + }{ + { + name: "entries_by_selector_found", + registrationEntries: allEntries, + selectors: []*common.Selector{ + {Type: "a", Value: "1"}, + {Type: "c", Value: "3"}, + }, + expectedList: []*common.RegistrationEntry{ + allEntries[0], + allEntries[3], + }, + }, + { + name: "entries_by_selector_not_found", + registrationEntries: allEntries, + selectors: []*common.Selector{ + {Type: "e", Value: "0"}, + }, + expectedList: nil, + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + ds := s.newPlugin() + defer ds.Close() + for _, entry := range test.registrationEntries { + registrationEntry, err := ds.CreateRegistrationEntry(ctx, entry) + require.NoError(t, err) + require.NotNil(t, registrationEntry) + entry.EntryId = registrationEntry.EntryId + } + result, err := ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + BySelectors: &datastore.BySelectors{ + Selectors: test.selectors, + Match: datastore.Superset, + }, + }) + require.NoError(t, err) + s.assertCreatedAtFields(result, now) + // Ordering is not guaranteed by the datastore interface, so we use a helper that ignores order + spiretest.RequireProtoListsSameEls(t, test.expectedList, result.Entries) + }) + } +} + +func (s *PluginSuite) TestListEntriesBySelectorMatchAny() { + now := time.Now().Unix() + allEntries := make([]*common.RegistrationEntry, 0) + s.getTestDataFromJSON(testdata.Entries, &allEntries) + tests := []struct { + name string + registrationEntries []*common.RegistrationEntry + selectors []*common.Selector + expectedList []*common.RegistrationEntry + }{ + { + name: "multiple selectors", + registrationEntries: allEntries, + selectors: []*common.Selector{ + {Type: "c", Value: "3"}, + {Type: "d", Value: "4"}, + }, + expectedList: []*common.RegistrationEntry{ + allEntries[0], + allEntries[2], + allEntries[3], + allEntries[4], + }, + }, + { + name: "single selector", + registrationEntries: allEntries, + selectors: []*common.Selector{ + {Type: "d", Value: "4"}, + }, + expectedList: []*common.RegistrationEntry{ + allEntries[3], + allEntries[4], + }, + }, + { + name: "no match", + registrationEntries: allEntries, + selectors: []*common.Selector{ + {Type: "e", Value: "5"}, + }, + expectedList: nil, + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + ds := s.newPlugin() + defer ds.Close() + for _, entry := range test.registrationEntries { + registrationEntry, err := ds.CreateRegistrationEntry(ctx, entry) + require.NoError(t, err) + require.NotNil(t, registrationEntry) + entry.EntryId = registrationEntry.EntryId + } + result, err := ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + BySelectors: &datastore.BySelectors{ + Selectors: test.selectors, + Match: datastore.MatchAny, + }, + }) + require.NoError(t, err) + util.SortRegistrationEntries(test.expectedList) + util.SortRegistrationEntries(result.Entries) + s.assertCreatedAtFields(result, now) + s.RequireProtoListEqual(test.expectedList, result.Entries) + }) + } +} + +func (s *PluginSuite) TestListEntriesByFederatesWithExact() { + now := time.Now().Unix() + allEntries := make([]*common.RegistrationEntry, 0) + s.getTestDataFromJSON(testdata.EntriesFederatesWith, &allEntries) + tests := []struct { + name string + registrationEntries []*common.RegistrationEntry + trustDomains []string + expectedList []*common.RegistrationEntry + }{ + { + name: "multiple selectors", + registrationEntries: allEntries, + trustDomains: []string{ + "spiffe://td1.org", + "spiffe://td2.org", + "spiffe://td3.org", + }, + expectedList: []*common.RegistrationEntry{ + allEntries[0], + }, + }, + { + name: "with a subset", + registrationEntries: allEntries, + trustDomains: []string{ + "spiffe://td1.org", + "spiffe://td2.org", + }, + expectedList: []*common.RegistrationEntry{ + allEntries[1], + }, + }, + { + name: "no match", + registrationEntries: allEntries, + trustDomains: []string{ + "spiffe://td1.org", + }, + expectedList: nil, + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + ds := s.newPlugin() + defer ds.Close() + createBundles(t, ds, []string{ + "spiffe://td1.org", + "spiffe://td2.org", + "spiffe://td3.org", + "spiffe://td4.org", + }) + + for _, entry := range test.registrationEntries { + registrationEntry, err := ds.CreateRegistrationEntry(ctx, entry) + require.NoError(t, err) + require.NotNil(t, registrationEntry) + entry.EntryId = registrationEntry.EntryId + } + result, err := ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + ByFederatesWith: &datastore.ByFederatesWith{ + TrustDomains: test.trustDomains, + Match: datastore.Exact, + }, + }) + require.NoError(t, err) + util.SortRegistrationEntries(test.expectedList) + util.SortRegistrationEntries(result.Entries) + + s.assertCreatedAtFields(result, now) + + spiretest.RequireProtoListEqual(t, test.expectedList, result.Entries) + }) + } +} + +func (s *PluginSuite) TestListEntriesByFederatesWithSubset() { + now := time.Now().Unix() + allEntries := make([]*common.RegistrationEntry, 0) + s.getTestDataFromJSON(testdata.EntriesFederatesWith, &allEntries) + tests := []struct { + name string + registrationEntries []*common.RegistrationEntry + trustDomains []string + expectedList []*common.RegistrationEntry + }{ + { + name: "multiple selectors", + registrationEntries: allEntries, + trustDomains: []string{ + "spiffe://td1.org", + "spiffe://td2.org", + "spiffe://td3.org", + }, + expectedList: []*common.RegistrationEntry{ + allEntries[0], + allEntries[1], + allEntries[2], + }, + }, + { + name: "no match", + registrationEntries: allEntries, + trustDomains: []string{ + "spiffe://td4.org", + }, + expectedList: nil, + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + ds := s.newPlugin() + defer ds.Close() + createBundles(t, ds, []string{ + "spiffe://td1.org", + "spiffe://td2.org", + "spiffe://td3.org", + "spiffe://td4.org", + }) + + for _, entry := range test.registrationEntries { + registrationEntry, err := ds.CreateRegistrationEntry(ctx, entry) + require.NoError(t, err) + require.NotNil(t, registrationEntry) + entry.EntryId = registrationEntry.EntryId + } + result, err := ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + ByFederatesWith: &datastore.ByFederatesWith{ + TrustDomains: test.trustDomains, + Match: datastore.Subset, + }, + }) + require.NoError(t, err) + util.SortRegistrationEntries(test.expectedList) + util.SortRegistrationEntries(result.Entries) + s.assertCreatedAtFields(result, now) + spiretest.RequireProtoListEqual(t, test.expectedList, result.Entries) + }) + } +} + +func (s *PluginSuite) TestListEntriesByFederatesWithMatchAny() { + now := time.Now().Unix() + allEntries := make([]*common.RegistrationEntry, 0) + s.getTestDataFromJSON(testdata.EntriesFederatesWith, &allEntries) + tests := []struct { + name string + registrationEntries []*common.RegistrationEntry + trustDomains []string + expectedList []*common.RegistrationEntry + }{ + { + name: "multiple selectors", + registrationEntries: allEntries, + trustDomains: []string{ + "spiffe://td3.org", + "spiffe://td4.org", + }, + expectedList: []*common.RegistrationEntry{ + allEntries[0], + allEntries[2], + allEntries[3], + allEntries[4], + }, + }, + { + name: "single selector", + registrationEntries: allEntries, + trustDomains: []string{"spiffe://td4.org"}, + expectedList: []*common.RegistrationEntry{ + allEntries[3], + allEntries[4], + }, + }, + { + name: "no match", + registrationEntries: allEntries, + trustDomains: []string{"spiffe://td5.org"}, + expectedList: nil, + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + ds := s.newPlugin() + defer ds.Close() + createBundles(t, ds, []string{ + "spiffe://td1.org", + "spiffe://td2.org", + "spiffe://td3.org", + "spiffe://td4.org", + }) + + for _, entry := range test.registrationEntries { + registrationEntry, err := ds.CreateRegistrationEntry(ctx, entry) + require.NoError(t, err) + require.NotNil(t, registrationEntry) + entry.EntryId = registrationEntry.EntryId + } + result, err := ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + ByFederatesWith: &datastore.ByFederatesWith{ + TrustDomains: test.trustDomains, + Match: datastore.MatchAny, + }, + }) + require.NoError(t, err) + util.SortRegistrationEntries(test.expectedList) + util.SortRegistrationEntries(result.Entries) + s.assertCreatedAtFields(result, now) + spiretest.RequireProtoListEqual(t, test.expectedList, result.Entries) + }) + } +} + +func (s *PluginSuite) TestListEntriesByFederatesWithSuperset() { + now := time.Now().Unix() + allEntries := make([]*common.RegistrationEntry, 0) + s.getTestDataFromJSON(testdata.EntriesFederatesWith, &allEntries) + tests := []struct { + name string + registrationEntries []*common.RegistrationEntry + trustDomains []string + expectedList []*common.RegistrationEntry + }{ + { + name: "multiple selectors", + registrationEntries: allEntries, + trustDomains: []string{ + "spiffe://td1.org", + "spiffe://td3.org", + }, + expectedList: []*common.RegistrationEntry{ + allEntries[0], + allEntries[3], + }, + }, + { + name: "single selector", + registrationEntries: allEntries, + trustDomains: []string{"spiffe://td3.org"}, + expectedList: []*common.RegistrationEntry{ + allEntries[0], + allEntries[2], + allEntries[3], + }, + }, + { + name: "no match", + registrationEntries: allEntries, + trustDomains: []string{"spiffe://td5.org"}, + expectedList: nil, + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + ds := s.newPlugin() + defer ds.Close() + createBundles(t, ds, []string{ + "spiffe://td1.org", + "spiffe://td2.org", + "spiffe://td3.org", + "spiffe://td4.org", + }) + + for _, entry := range test.registrationEntries { + registrationEntry, err := ds.CreateRegistrationEntry(ctx, entry) + require.NoError(t, err) + require.NotNil(t, registrationEntry) + entry.EntryId = registrationEntry.EntryId + } + result, err := ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + ByFederatesWith: &datastore.ByFederatesWith{ + TrustDomains: test.trustDomains, + Match: datastore.Superset, + }, + }) + require.NoError(t, err) + util.SortRegistrationEntries(test.expectedList) + util.SortRegistrationEntries(result.Entries) + s.assertCreatedAtFields(result, now) + spiretest.RequireProtoListEqual(t, test.expectedList, result.Entries) + }) + } +} + +func (s *PluginSuite) TestRegistrationEntriesFederatesWithAgainstMissingBundle() { + // cannot federate with a trust bundle that does not exist + _, err := s.ds.CreateRegistrationEntry(ctx, makeFederatedRegistrationEntry()) + s.RequireErrorContains(err, `unable to find federated bundle "spiffe://otherdomain.org"`) +} + +func (s *PluginSuite) TestRegistrationEntriesFederatesWithSuccess() { + // create two bundles but only federate with one. having a second bundle + // has the side effect of asserting that only the code only associates + // the entry with the exact bundle referenced during creation. + s.createBundle("spiffe://otherdomain.org") + s.createBundle("spiffe://otherdomain2.org") + + expected := s.createRegistrationEntry(makeFederatedRegistrationEntry()) + // fetch the entry and make sure the federated trust ids come back + actual := s.fetchRegistrationEntry(expected.EntryId) + s.RequireProtoEqual(expected, actual) +} + +func (s *PluginSuite) TestDeleteBundleRestrictedByRegistrationEntries() { + // create the bundle and associated entry + s.createBundle("spiffe://otherdomain.org") + s.createRegistrationEntry(makeFederatedRegistrationEntry()) + + // delete the bundle in RESTRICTED mode + err := s.ds.DeleteBundle(context.Background(), "spiffe://otherdomain.org", datastore.Restrict) + s.RequireErrorContains(err, "cannot delete bundle; federated with 1 registration entries") +} + +func (s *PluginSuite) TestDeleteBundleDeleteRegistrationEntries() { + // create an unrelated registration entry to make sure the delete + // operation only deletes associated registration entries. + unrelated := s.createRegistrationEntry(&common.RegistrationEntry{ + SpiffeId: "spiffe://example.org/foo", + Selectors: []*common.Selector{{Type: "TYPE", Value: "VALUE"}}, + }) + + // create the bundle and associated entry + s.createBundle("spiffe://otherdomain.org") + entry := s.createRegistrationEntry(makeFederatedRegistrationEntry()) + + // delete the bundle in Delete mode + err := s.ds.DeleteBundle(context.Background(), "spiffe://otherdomain.org", datastore.Delete) + s.Require().NoError(err) + + // verify that the registration entry has been deleted + registrationEntry, err := s.ds.FetchRegistrationEntry(context.Background(), entry.EntryId) + s.Require().NoError(err) + s.Require().Nil(registrationEntry) + + // make sure the unrelated entry still exists + s.fetchRegistrationEntry(unrelated.EntryId) +} + +func (s *PluginSuite) TestDeleteBundleDissociateRegistrationEntries() { + // create the bundle and associated entry + s.createBundle("spiffe://otherdomain.org") + entry := s.createRegistrationEntry(makeFederatedRegistrationEntry()) + + // delete the bundle in DISSOCIATE mode + err := s.ds.DeleteBundle(context.Background(), "spiffe://otherdomain.org", datastore.Dissociate) + s.Require().NoError(err) + + // make sure the entry still exists, albeit without an associated bundle + entry = s.fetchRegistrationEntry(entry.EntryId) + s.Require().Empty(entry.FederatesWith) +} + +func (s *PluginSuite) TestListRegistrationEntryEvents() { + var expectedEvents []datastore.RegistrationEntryEvent + var expectedEventID uint = 1 + + // Create an entry + entry1 := s.createRegistrationEntry(&common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + }, + SpiffeId: "spiffe://example.org/foo1", + ParentId: "spiffe://example.org/bar", + }) + expectedEvents = append(expectedEvents, datastore.RegistrationEntryEvent{ + EventID: expectedEventID, + EntryID: entry1.EntryId, + }) + expectedEventID++ + + resp, err := s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{}) + s.Require().NoError(err) + s.Require().Equal(expectedEvents, resp.Events) + + // Create second entry + entry2 := s.createRegistrationEntry(&common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type2", Value: "Value2"}, + }, + SpiffeId: "spiffe://example.org/foo2", + ParentId: "spiffe://example.org/bar", + }) + expectedEvents = append(expectedEvents, datastore.RegistrationEntryEvent{ + EventID: expectedEventID, + EntryID: entry2.EntryId, + }) + expectedEventID++ + + resp, err = s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{}) + s.Require().NoError(err) + s.Require().Equal(expectedEvents, resp.Events) + + // Update first entry + updatedRegistrationEntry, err := s.ds.UpdateRegistrationEntry(ctx, entry1, nil) + s.Require().NoError(err) + expectedEvents = append(expectedEvents, datastore.RegistrationEntryEvent{ + EventID: expectedEventID, + EntryID: updatedRegistrationEntry.EntryId, + }) + expectedEventID++ + + resp, err = s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{}) + s.Require().NoError(err) + s.Require().Equal(expectedEvents, resp.Events) + + // Delete second entry + s.deleteRegistrationEntry(entry2.EntryId) + expectedEvents = append(expectedEvents, datastore.RegistrationEntryEvent{ + EventID: expectedEventID, + EntryID: entry2.EntryId, + }) + + resp, err = s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{}) + s.Require().NoError(err) + s.Require().Equal(expectedEvents, resp.Events) + + // Check filtering events by id + tests := []struct { + name string + greaterThanEventID uint + lessThanEventID uint + expectedEvents []datastore.RegistrationEntryEvent + expectedFirstEventID uint + expectedLastEventID uint + expectedErr string + }{ + { + name: "All Events", + greaterThanEventID: 0, + expectedFirstEventID: 1, + expectedLastEventID: uint(len(expectedEvents)), + expectedEvents: expectedEvents, + }, + { + name: "Greater than half of the Events", + greaterThanEventID: uint(len(expectedEvents) / 2), + expectedFirstEventID: uint(len(expectedEvents)/2) + 1, + expectedLastEventID: uint(len(expectedEvents)), + expectedEvents: expectedEvents[len(expectedEvents)/2:], + }, + { + name: "Less than half of the Events", + lessThanEventID: uint(len(expectedEvents) / 2), + expectedFirstEventID: 1, + expectedLastEventID: uint(len(expectedEvents)/2) - 1, + expectedEvents: expectedEvents[:len(expectedEvents)/2-1], + }, + { + name: "Greater than largest Event ID", + greaterThanEventID: 4, + expectedEvents: []datastore.RegistrationEntryEvent{}, + }, + { + name: "Setting both greater and less than", + greaterThanEventID: 1, + lessThanEventID: 1, + expectedErr: "can't set both greater and less than event id", + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + resp, err = s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{ + GreaterThanEventID: test.greaterThanEventID, + LessThanEventID: test.lessThanEventID, + }) + if test.expectedErr != "" { + require.ErrorContains(t, err, test.expectedErr) + return + } + s.Require().NoError(err) + + s.Require().Equal(test.expectedEvents, resp.Events) + if len(resp.Events) > 0 { + s.Require().Equal(test.expectedFirstEventID, resp.Events[0].EventID) + s.Require().Equal(test.expectedLastEventID, resp.Events[len(resp.Events)-1].EventID) + } + }) + } +} + +func (s *PluginSuite) TestPruneRegistrationEntryEvents() { + entry := &common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + }, + SpiffeId: "SpiffeId", + ParentId: "ParentId", + } + + createdRegistrationEntry := s.createRegistrationEntry(entry) + resp, err := s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{}) + s.Require().NoError(err) + s.Require().Equal(createdRegistrationEntry.EntryId, resp.Events[0].EntryID) + + for _, tt := range []struct { + name string + olderThan time.Duration + expectedEvents []datastore.RegistrationEntryEvent + }{ + { + name: "Don't prune valid events", + olderThan: 1 * time.Hour, + expectedEvents: []datastore.RegistrationEntryEvent{ + { + EventID: 1, + EntryID: createdRegistrationEntry.EntryId, + }, + }, + }, + { + name: "Prune old events", + olderThan: 0 * time.Second, + expectedEvents: []datastore.RegistrationEntryEvent{}, + }, + } { + s.T().Run(tt.name, func(t *testing.T) { + s.Require().EventuallyWithTf(func(collect *assert.CollectT) { + err := s.ds.PruneRegistrationEntryEvents(ctx, tt.olderThan) + require.NoError(collect, err) + + resp, err := s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{}) + require.NoError(collect, err) + + assert.True(collect, reflect.DeepEqual(tt.expectedEvents, resp.Events)) + }, 10*time.Second, 50*time.Millisecond, "Failed to prune entries correctly") + }) + } +} + +func (s *PluginSuite) TestCreateJoinToken() { + req := &datastore.JoinToken{ + Token: "foobar", + Expiry: time.Now().Truncate(time.Second), + } + err := s.ds.CreateJoinToken(ctx, req) + s.Require().NoError(err) + + // Make sure we can't re-register + err = s.ds.CreateJoinToken(ctx, req) + s.NotNil(err) +} + +func (s *PluginSuite) TestCreateAndFetchJoinToken() { + now := time.Now().Truncate(time.Second) + joinToken := &datastore.JoinToken{ + Token: "foobar", + Expiry: now, + } + + err := s.ds.CreateJoinToken(ctx, joinToken) + s.Require().NoError(err) + + res, err := s.ds.FetchJoinToken(ctx, joinToken.Token) + s.Require().NoError(err) + s.Equal("foobar", res.Token) + s.Equal(now, res.Expiry) +} + +func (s *PluginSuite) TestDeleteJoinToken() { + now := time.Now().Truncate(time.Second) + joinToken1 := &datastore.JoinToken{ + Token: "foobar", + Expiry: now, + } + + err := s.ds.CreateJoinToken(ctx, joinToken1) + s.Require().NoError(err) + + joinToken2 := &datastore.JoinToken{ + Token: "batbaz", + Expiry: now, + } + + err = s.ds.CreateJoinToken(ctx, joinToken2) + s.Require().NoError(err) + + err = s.ds.DeleteJoinToken(ctx, joinToken1.Token) + s.Require().NoError(err) + + // Should not be able to fetch after delete + resp, err := s.ds.FetchJoinToken(ctx, joinToken1.Token) + s.Require().NoError(err) + s.Nil(resp) + + // Second token should still be present + resp, err = s.ds.FetchJoinToken(ctx, joinToken2.Token) + s.Require().NoError(err) + s.Equal(joinToken2, resp) +} + +func (s *PluginSuite) TestPruneJoinTokens() { + now := time.Now().Truncate(time.Second) + joinToken := &datastore.JoinToken{ + Token: "foobar", + Expiry: now, + } + + err := s.ds.CreateJoinToken(ctx, joinToken) + s.Require().NoError(err) + + // Ensure we don't prune valid tokens, wind clock back 10s + err = s.ds.PruneJoinTokens(ctx, now.Add(-time.Second*10)) + s.Require().NoError(err) + + resp, err := s.ds.FetchJoinToken(ctx, joinToken.Token) + s.Require().NoError(err) + s.Equal("foobar", resp.Token) + + // Ensure we don't prune on the exact ExpiresBefore + err = s.ds.PruneJoinTokens(ctx, now) + s.Require().NoError(err) + + resp, err = s.ds.FetchJoinToken(ctx, joinToken.Token) + s.Require().NoError(err) + s.Require().NotNil(resp, "token was unexpectedly pruned") + s.Equal("foobar", resp.Token) + + // Ensure we prune old tokens + err = s.ds.PruneJoinTokens(ctx, now.Add(time.Second*10)) + s.Require().NoError(err) + + resp, err = s.ds.FetchJoinToken(ctx, joinToken.Token) + s.Require().NoError(err) + s.Nil(resp) +} + +func (s *PluginSuite) TestDeleteFederationRelationship() { + testCases := []struct { + name string + trustDomain spiffeid.TrustDomain + expErr string + expErrStatus codes.Code + setupFn func() + }{ + { + name: "deleting an existent federation relationship succeeds", + trustDomain: spiffeid.RequireTrustDomainFromString("federated-td-web.org"), + setupFn: func() { + _, err := s.ds.CreateFederationRelationship(ctx, &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("federated-td-web.org"), + BundleEndpointURL: requireURLFromString(s.T(), "federated-td-web.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + }) + s.Require().NoError(err) + }, + }, + { + name: "deleting an unexistent federation relationship returns not found", + trustDomain: spiffeid.RequireTrustDomainFromString("non-existent-td.org"), + expErr: _notFoundErrMsg, + expErrStatus: codes.NotFound, + }, + { + name: "deleting a federation relationship using an empty trust domain fails nicely", + expErr: wrapErrMsg("trust domain is required"), + expErrStatus: codes.InvalidArgument, + }, + } + + for _, tt := range testCases { + s.T().Run(tt.name, func(t *testing.T) { + if tt.setupFn != nil { + tt.setupFn() + } + + err := s.ds.DeleteFederationRelationship(ctx, tt.trustDomain) + if tt.expErr != "" { + s.AssertGRPCStatus(err, tt.expErrStatus, tt.expErr) + return + } + s.Require().NoError(err) + + fr, err := s.ds.FetchFederationRelationship(ctx, tt.trustDomain) + s.Require().NoError(err) + s.Require().Nil(fr) + }) + } +} + +func (s *PluginSuite) TestFetchFederationRelationship() { + testCases := []struct { + name string + trustDomain spiffeid.TrustDomain + expErr string + expectedStatus codes.Code + expFR *datastore.FederationRelationship + }{ + { + name: "fetching an existent federation relationship succeeds for web profile", + trustDomain: spiffeid.RequireTrustDomainFromString("federated-td-web.org"), + expFR: func() *datastore.FederationRelationship { + fr, err := s.ds.CreateFederationRelationship(ctx, &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("federated-td-web.org"), + BundleEndpointURL: requireURLFromString(s.T(), "federated-td-web.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + }) + s.Require().NoError(err) + return fr + }(), + }, + { + name: "fetching an existent federation relationship succeeds for spiffe profile", + trustDomain: spiffeid.RequireTrustDomainFromString("federated-td-spiffe.org"), + expFR: func() *datastore.FederationRelationship { + trustDomainBundle := s.createBundle("spiffe://federated-td-spiffe.org") + fr, err := s.ds.CreateFederationRelationship(ctx, &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("federated-td-spiffe.org"), + BundleEndpointURL: requireURLFromString(s.T(), "federated-td-spiffe.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://federated-td-spiffe.org/federated-server"), + TrustDomainBundle: trustDomainBundle, + }) + s.Require().NoError(err) + return fr + }(), + }, + { + name: "fetching an existent federation relationship succeeds for profile without bundle", + trustDomain: spiffeid.RequireTrustDomainFromString("domain.test"), + expFR: func() *datastore.FederationRelationship { + fr, err := s.ds.CreateFederationRelationship(ctx, &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("domain.test"), + BundleEndpointURL: requireURLFromString(s.T(), "https://domain.test/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://domain.test/federated-server"), + }) + s.Require().NoError(err) + return fr + }(), + }, + { + name: "fetching a non-existent federation relationship returns nil", + trustDomain: spiffeid.RequireTrustDomainFromString("non-existent-td.org"), + }, + { + name: "fetching en empty trust domain fails nicely", + expErr: "trust domain is required", + expectedStatus: codes.InvalidArgument, + }, + // TODO(tjons): document and justify the removal of these three SQL-specific tests from the shared test harness. + // { + // name: "fetching a federation relationship with corrupted bundle endpoint URL fails nicely", + // expErr: "rpc error: code = Unknown desc = unable to parse URL: parse \"not-valid-endpoint-url%\": invalid URL escape \"%\"", + // trustDomain: spiffeid.RequireTrustDomainFromString("corrupted-bundle-endpoint-url.org"), + // expFR: func() *FederationRelationship { //nolint // returns nil on purpose + // model := FederatedTrustDomain{ + // TrustDomain: "corrupted-bundle-endpoint-url.org", + // BundleEndpointURL: "not-valid-endpoint-url%", + // BundleEndpointProfile: string(BundleEndpointWeb), + // } + // s.Require().NoError(s.ds.db.Create(&model).Error) + // return nil + // }(), + // }, + // { + // name: "fetching a federation relationship with corrupted bundle endpoint SPIFFE ID fails nicely", + // expErr: "rpc error: code = Unknown desc = unable to parse bundle endpoint SPIFFE ID: scheme is missing or invalid", + // trustDomain: spiffeid.RequireTrustDomainFromString("corrupted-bundle-endpoint-id.org"), + // expFR: func() *FederationRelationship { //nolint // returns nil on purpose + // model := FederatedTrustDomain{ + // TrustDomain: "corrupted-bundle-endpoint-id.org", + // BundleEndpointURL: "corrupted-bundle-endpoint-id.org/bundleendpoint", + // BundleEndpointProfile: string(BundleEndpointSPIFFE), + // EndpointSPIFFEID: "invalid-id", + // } + // s.Require().NoError(s.ds.db.Create(&model).Error) + // return nil + // }(), + // }, + // { + // name: "fetching a federation relationship with corrupted type fails nicely", + // expErr: "rpc error: code = Unknown desc = unknown bundle endpoint profile type: \"other\"", + // trustDomain: spiffeid.RequireTrustDomainFromString("corrupted-endpoint-profile.org"), + // expFR: func() *FederationRelationship { //nolint // returns nil on purpose + // model := sqlstore.FederatedTrustDomain{ + // TrustDomain: "corrupted-endpoint-profile.org", + // BundleEndpointURL: "corrupted-endpoint-profile.org/bundleendpoint", + // BundleEndpointProfile: "other", + // } + // s.Require().NoError(s.ds.db.Create(&model).Error) + // return nil + // }(), + // }, + } + + for _, tt := range testCases { + s.T().Run(tt.name, func(t *testing.T) { + fr, err := s.ds.FetchFederationRelationship(ctx, tt.trustDomain) + if tt.expErr != "" { + s.RequireGRPCStatus(err, tt.expectedStatus, wrapErrMsg(tt.expErr)) + require.Nil(t, fr) + return + } + + require.NoError(t, err) + assertFederationRelationship(t, tt.expFR, fr) + }) + } +} + +func (s *PluginSuite) TestCreateFederationRelationship() { + s.createBundle("spiffe://federated-td-spiffe.org") + s.createBundle("spiffe://federated-td-spiffe-with-bundle.org") + + testCases := []struct { + name string + expectCode codes.Code + expectMsg string + fr *datastore.FederationRelationship + }{ + { + name: "creating a new federation relationship succeeds for web profile", + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("federated-td-web.org"), + BundleEndpointURL: requireURLFromString(s.T(), "federated-td-web.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + }, + }, + { + name: "creating a new federation relationship succeeds for spiffe profile", + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("federated-td-spiffe.org"), + BundleEndpointURL: requireURLFromString(s.T(), "federated-td-spiffe.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://federated-td-spiffe.org/federated-server"), + }, + }, + { + name: "creating a new federation relationship succeeds for web profile and new bundle", + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("federated-td-web-with-bundle.org"), + BundleEndpointURL: requireURLFromString(s.T(), "federated-td-web-with-bundle.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + TrustDomainBundle: func() *common.Bundle { + newBundle := bundleutil.BundleProtoFromRootCA("spiffe://federated-td-web-with-bundle.org", s.cert) + newBundle.RefreshHint = int64(10) // modify bundle to assert it was updated + return newBundle + }(), + }, + }, + { + name: "creating a new federation relationship succeeds for spiffe profile and new bundle", + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("federated-td-spiffe-with-bundle.org"), + BundleEndpointURL: requireURLFromString(s.T(), "federated-td-spiffe-with-bundle.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://federated-td-spiffe-with-bundle.org/federated-server"), + TrustDomainBundle: func() *common.Bundle { + newBundle := bundleutil.BundleProtoFromRootCA("spiffe://federated-td-spiffe-with-bundle.org", s.cert) + newBundle.RefreshHint = int64(10) // modify bundle to assert it was updated + return newBundle + }(), + }, + }, + { + name: "creating a new nil federation relationship fails nicely ", + expectCode: codes.InvalidArgument, + expectMsg: "federation relationship is nil", + }, + { + name: "creating a new federation relationship without trust domain fails nicely ", + expectCode: codes.InvalidArgument, + expectMsg: "trust domain is required", + fr: &datastore.FederationRelationship{ + BundleEndpointURL: requireURLFromString(s.T(), "federated-td-web.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + }, + }, + { + name: "creating a new federation relationship without bundle endpoint URL fails nicely", + expectCode: codes.InvalidArgument, + expectMsg: "bundle endpoint URL is required", + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("federated-td-spiffe.org"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://federated-td-spiffe.org/federated-server"), + }, + }, + { + name: "creating a new SPIFFE federation relationship without bundle endpoint SPIFFE ID fails nicely", + expectCode: codes.InvalidArgument, + expectMsg: "bundle endpoint SPIFFE ID is required", + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("federated-td-spiffe.org"), + BundleEndpointURL: requireURLFromString(s.T(), "federated-td-spiffe.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + }, + }, + { + name: "creating a new SPIFFE federation relationship without initial bundle pass", + expectCode: codes.OK, + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("no-initial-bundle.org"), + BundleEndpointURL: requireURLFromString(s.T(), "no-initial-bundle.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://no-initial-bundle.org/federated-server"), + }, + }, + { + name: "creating a new federation relationship of unknown type fails nicely", + expectCode: codes.Unknown, + expectMsg: "unknown bundle endpoint profile type: \"wrong-type\"", + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("no-initial-bundle.org"), + BundleEndpointURL: requireURLFromString(s.T(), "no-initial-bundle.org/bundleendpoint"), + BundleEndpointProfile: "wrong-type", + }, + }, + } + + for _, tt := range testCases { + s.T().Run(tt.name, func(t *testing.T) { + fr, err := s.ds.CreateFederationRelationship(ctx, tt.fr) + spiretest.RequireGRPCStatus(t, err, tt.expectCode, wrapErrMsg(tt.expectMsg)) + if tt.expectCode != codes.OK { + require.Nil(t, fr) + return + } + // TODO: when FetchFederationRelationship is implemented, assert if entry was created + + switch fr.BundleEndpointProfile { + case datastore.BundleEndpointWeb: + case datastore.BundleEndpointSPIFFE: + default: + require.FailNowf(t, "unexpected bundle endpoint profile type: %q", string(fr.BundleEndpointProfile)) + } + + if fr.TrustDomainBundle != nil { + // Assert bundle is updated + bundle, err := s.ds.FetchBundle(ctx, fr.TrustDomain.IDString()) + require.NoError(t, err) + spiretest.RequireProtoEqual(t, bundle, fr.TrustDomainBundle) + } + }) + } +} + +func (s *PluginSuite) TestListFederationRelationships() { + fr1 := &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("spiffe://example-1.org"), + BundleEndpointURL: requireURLFromString(s.T(), "https://example-1-web.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + } + _, err := s.ds.CreateFederationRelationship(ctx, fr1) + s.Require().NoError(err) + + trustDomainBundle := s.createBundle("spiffe://example-2.org") + fr2 := &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("spiffe://example-2.org"), + BundleEndpointURL: requireURLFromString(s.T(), "https://example-2-web.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://example-2.org/test"), + TrustDomainBundle: trustDomainBundle, + } + _, err = s.ds.CreateFederationRelationship(ctx, fr2) + s.Require().NoError(err) + + fr3 := &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("spiffe://example-3.org"), + BundleEndpointURL: requireURLFromString(s.T(), "https://example-3-web.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://example-2.org/test"), + } + _, err = s.ds.CreateFederationRelationship(ctx, fr3) + s.Require().NoError(err) + + fr4 := &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("spiffe://example-4.org"), + BundleEndpointURL: requireURLFromString(s.T(), "https://example-4-web.org/bundleendpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + } + _, err = s.ds.CreateFederationRelationship(ctx, fr4) + s.Require().NoError(err) + + tests := []struct { + name string + pagination *datastore.Pagination + expectedList []*datastore.FederationRelationship + expectedPagination *datastore.Pagination + expectedStatusCode codes.Code + expectedErr string + }{ + { + name: "pagination page size is zero", + pagination: &datastore.Pagination{ + PageSize: 0, + }, + expectedErr: "cannot paginate with pagesize = 0", + expectedStatusCode: codes.InvalidArgument, + }, + { + name: "invalid token", + expectedList: []*datastore.FederationRelationship{}, + expectedErr: "could not parse token 'invalid token'", + expectedStatusCode: codes.InvalidArgument, + pagination: &datastore.Pagination{ + Token: "invalid token", + PageSize: 2, + }, + expectedPagination: &datastore.Pagination{ + PageSize: 2, + }, + }, + } + for _, test := range tests { + s.T().Run(test.name, func(t *testing.T) { + req := &datastore.ListFederationRelationshipsRequest{ + Pagination: test.pagination, + } + + resp, err := s.ds.ListFederationRelationships(ctx, req) + if test.expectedErr != "" { + spiretest.AssertGRPCStatus(t, err, test.expectedStatusCode, wrapErrMsg(test.expectedErr)) + return + } + require.NoError(t, err) + require.NotNil(t, resp) + }) + } + + s.T().Run("standard paging endpoint test", func(t *testing.T) { + listTest := NewPaginationTest[datastore.FederationRelationship]("ListFederationRelationshipsWithPagination"). + WithExpectOrder(false). + WithExpectedItems([]datastore.FederationRelationship{*fr1, *fr2, *fr3, *fr4}). + WithPageSize(2). + WithAssertionFunc(func(t *testing.T, fr1, fr2 datastore.FederationRelationship) { + // assertFederationRelationship takes pointers, but the generics here are literals, so we convert + assertFederationRelationship(t, &fr1, &fr2) + }). + WithIdentifier(func(fr datastore.FederationRelationship) string { + return fr.TrustDomain.IDString() + }). + WithLister(func(p *datastore.Pagination) ([]datastore.FederationRelationship, *datastore.Pagination, error) { + resp, err := s.ds.ListFederationRelationships(ctx, &datastore.ListFederationRelationshipsRequest{ + + Pagination: p, + }) + if err != nil { + return nil, nil, err + } + + relationships := make([]datastore.FederationRelationship, len(resp.FederationRelationships)) + for i, fr := range resp.FederationRelationships { + relationships[i] = *fr + } + return relationships, resp.Pagination, nil + }) + + for listTest.NextPage() { + s.Require().NoError(listTest.Get()) + } + + // common should error with invalid pagination + listTest.Assert(s.T()) + listTest.AssertNoPagination(s.T()) + listTest.AssertBigPage(s.T()) + }) +} + +func (s *PluginSuite) TestUpdateFederationRelationship() { + s.createBundle("spiffe://td-with-bundle.org") + + testCases := []struct { + name string + initialFR *datastore.FederationRelationship + fr *datastore.FederationRelationship + mask *types.FederationRelationshipMask + expFR *datastore.FederationRelationship + expErrMsg string + expErrCode codes.Code + }{ + { + name: "updating bundle endpoint URL succeeds", + initialFR: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td.org"), + BundleEndpointURL: requireURLFromString(s.T(), "td.org/bundle-endpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + }, + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td.org"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + BundleEndpointURL: requireURLFromString(s.T(), "td.org/other-bundle-endpoint"), + }, + mask: &types.FederationRelationshipMask{BundleEndpointUrl: true}, + expFR: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td.org"), + BundleEndpointURL: requireURLFromString(s.T(), "td.org/other-bundle-endpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + }, + }, + { + name: "updating bundle endpoint profile with pre-existent bundle and no input bundle succeeds", + initialFR: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td-with-bundle.org"), + BundleEndpointURL: requireURLFromString(s.T(), "td-with-bundle.org/bundle-endpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + }, + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td-with-bundle.org"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://td-with-bundle.org/federated-server"), + }, + mask: &types.FederationRelationshipMask{BundleEndpointProfile: true}, + expFR: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td-with-bundle.org"), + BundleEndpointURL: requireURLFromString(s.T(), "td-with-bundle.org/bundle-endpoint"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://td-with-bundle.org/federated-server"), + TrustDomainBundle: bundleutil.BundleProtoFromRootCA("spiffe://td-with-bundle.org", s.cert), + }, + }, + { + name: "updating bundle endpoint profile with pre-existent bundle and input bundle succeeds", + initialFR: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td-with-bundle.org"), + BundleEndpointURL: requireURLFromString(s.T(), "td-with-bundle.org/bundle-endpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + }, + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td-with-bundle.org"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://td-with-bundle.org/federated-server"), + TrustDomainBundle: func() *common.Bundle { + newBundle := bundleutil.BundleProtoFromRootCA("spiffe://td-with-bundle.org", s.cert) + newBundle.RefreshHint = int64(10) // modify bundle to assert it was updated + return newBundle + }(), + }, + mask: &types.FederationRelationshipMask{BundleEndpointProfile: true}, + expFR: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td-with-bundle.org"), + BundleEndpointURL: requireURLFromString(s.T(), "td-with-bundle.org/bundle-endpoint"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://td-with-bundle.org/federated-server"), + TrustDomainBundle: func() *common.Bundle { + newBundle := bundleutil.BundleProtoFromRootCA("spiffe://td-with-bundle.org", s.cert) + newBundle.RefreshHint = int64(10) + return newBundle + }(), + }, + }, + { + name: "updating bundle endpoint profile to SPIFFE without pre-existent bundle succeeds", + initialFR: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td-without-bundle.org"), + BundleEndpointURL: requireURLFromString(s.T(), "td-without-bundle.org/bundle-endpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + }, + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td-without-bundle.org"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://td-without-bundle.org/federated-server"), + TrustDomainBundle: bundleutil.BundleProtoFromRootCA("spiffe://td-without-bundle.org", s.cert), + }, + mask: &types.FederationRelationshipMask{BundleEndpointProfile: true}, + expFR: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td-without-bundle.org"), + BundleEndpointURL: requireURLFromString(s.T(), "td-without-bundle.org/bundle-endpoint"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://td-without-bundle.org/federated-server"), + TrustDomainBundle: bundleutil.BundleProtoFromRootCA("spiffe://td-without-bundle.org", s.cert), + }, + }, + { + name: "updating bundle endpoint profile to without pre-existent bundle and no input bundle pass", + initialFR: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td.org"), + BundleEndpointURL: requireURLFromString(s.T(), "td.org/bundle-endpoint"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + }, + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td.org"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://td.org/federated-server"), + }, + expFR: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td.org"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://td.org/federated-server"), + BundleEndpointURL: requireURLFromString(s.T(), "td.org/bundle-endpoint"), + }, + mask: &types.FederationRelationshipMask{BundleEndpointProfile: true}, + }, + { + name: "updating federation relationship for non-existent trust domain fails nicely", + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("non-existent-td.org"), + BundleEndpointProfile: datastore.BundleEndpointWeb, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://td.org/federated-server"), + }, + mask: &types.FederationRelationshipMask{BundleEndpointProfile: true}, + expErrMsg: "unable to fetch federation relationship: record not found", + expErrCode: codes.NotFound, + }, + { + name: "updating a nil federation relationship fails nicely ", + expErrMsg: "federation relationship is required", + expErrCode: codes.InvalidArgument, + }, + { + name: "updating a federation relationship without trust domain fails nicely ", + expErrMsg: "trust domain is required", + expErrCode: codes.InvalidArgument, + fr: &datastore.FederationRelationship{ + BundleEndpointProfile: datastore.BundleEndpointWeb, // TODO(tjons): if we add unknown values to the enum, we could remove this + }, + }, + { + name: "updating a federation relationship without bundle endpoint URL fails nicely", + expErrMsg: "bundle endpoint URL is required", + expErrCode: codes.InvalidArgument, + mask: protoutil.AllTrueFederationRelationshipMask, + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td.org"), + BundleEndpointProfile: datastore.BundleEndpointSPIFFE, + EndpointSPIFFEID: spiffeid.RequireFromString("spiffe://td.org/federated-server"), + }, + }, + { + name: "updating a federation relationship of unknown type fails nicely", + expErrMsg: "unknown bundle endpoint profile type: \"wrong-type\"", // TODO(tjons): this doesn't work the same way between SQL and Cassandra + expErrCode: codes.InvalidArgument, + mask: protoutil.AllTrueFederationRelationshipMask, + fr: &datastore.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString("td.org"), + BundleEndpointURL: requireURLFromString(s.T(), "td.org/bundle-endpoint"), + BundleEndpointProfile: "wrong-type", + }, + }, + } + + for _, tt := range testCases { + s.T().Run(tt.name, func(t *testing.T) { + if tt.initialFR != nil { + _, err := s.ds.CreateFederationRelationship(ctx, tt.initialFR) + s.Require().NoError(err) + defer func() { s.Require().NoError(s.ds.DeleteFederationRelationship(ctx, tt.initialFR.TrustDomain)) }() + } + + updatedFR, err := s.ds.UpdateFederationRelationship(ctx, tt.fr, tt.mask) + if tt.expErrMsg != "" { + s.RequireGRPCStatus(err, tt.expErrCode, wrapErrMsg(tt.expErrMsg)) + s.Require().Nil(updatedFR) + return + } + s.Require().NoError(err) + s.Require().NotNil(updatedFR) + + switch tt.expFR.BundleEndpointProfile { + case datastore.BundleEndpointWeb: + case datastore.BundleEndpointSPIFFE: + // Assert bundle is updated + bundle, err := s.ds.FetchBundle(ctx, tt.expFR.TrustDomain.IDString()) + s.Require().NoError(err) + s.RequireProtoEqual(bundle, updatedFR.TrustDomainBundle) + + // Now that bundles were asserted, set them to nil to be able to compare other fields using Require().Equal + tt.expFR.TrustDomainBundle = nil + updatedFR.TrustDomainBundle = nil + default: + s.Require().FailNowf("unexpected bundle endpoint profile type: %q", string(tt.expFR.BundleEndpointProfile)) + } + + s.Require().Equal(tt.expFR, updatedFR) + }) + } +} + +// TODO(tjons): document and justify the removal of this SQL-specific test from the shared test harness. + +// func (s *PluginSuite) TestMigration() { +// for schemaVersion := range latestSchemaVersion { +// s.T().Run(fmt.Sprintf("migration_from_schema_version_%d", schemaVersion), func(t *testing.T) { +// require := require.New(t) +// dbName := fmt.Sprintf("v%d.sqlite3", schemaVersion) +// dbPath := filepath.ToSlash(filepath.Join(s.dir, "migration-"+dbName)) +// if runtime.GOOS == "windows" { +// dbPath = "/" + dbPath +// } +// dbURI := fmt.Sprintf("file://%s", dbPath) + +// minimalDB := func() string { +// previousMinor := codeVersion +// if codeVersion.Minor == 0 { +// previousMinor.Major-- +// } else { +// previousMinor.Minor-- +// } +// return fmt.Sprintf(` +// CREATE TABLE "migrations" ("id" integer primary key autoincrement, "version" integer,"code_version" varchar(255) ); +// INSERT INTO migrations("version", "code_version") VALUES (%d,%q); +// `, schemaVersion, previousMinor) +// } + +// prepareDB := func(migrationSupported bool) { +// dump := migrationDumps[schemaVersion] +// if migrationSupported { +// require.NotEmpty(dump, "no migration dump set up for schema version") +// } else { +// require.Empty(dump, "migration dump exists for unsupported schema version") +// dump = minimalDB() +// } +// dumpDB(t, dbPath, dump) +// err := s.ds.Configure(ctx, fmt.Sprintf(` +// database_type = "sqlite3" +// connection_string = %q +// `, dbURI)) +// if migrationSupported { +// require.NoError(err) +// } else { +// require.EqualError(err, fmt.Sprintf("datastore-sql: migrating from schema version %d requires a previous SPIRE release; please follow the upgrade strategy at doc/upgrading.md", schemaVersion)) +// } +// } +// switch schemaVersion { +// // All of these schema versions were migrated by previous versions +// // of SPIRE server and no longer have migration code. +// case 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22: +// prepareDB(false) +// default: +// t.Fatalf("no migration test added for schema version %d", schemaVersion) +// } +// }) +// } +// } + +// TODO(tjons): document and justify the removal of this SQL-specific test from the shared test harness. +// +// We will need something like this for cassandra. +// +// func (s *PluginSuite) TestPristineDatabaseMigrationValues() { +// var m Migration +// s.Require().NoError(s.ds.db.First(&m).Error) +// s.Equal(latestSchemaVersion, m.Version) +// s.Equal(codeVersion.String(), m.CodeVersion) +// } + +func (s *PluginSuite) TestRace() { + next := int64(0) + exp := time.Now().Add(time.Hour).Unix() + + testutil.RaceTest(s.T(), func(t *testing.T) { + node := &common.AttestedNode{ + SpiffeId: fmt.Sprintf("foo%d", atomic.AddInt64(&next, 1)), + AttestationDataType: "aws-tag", + CertSerialNumber: "badcafe", + CertNotAfter: exp, + } + + _, err := s.ds.CreateAttestedNode(ctx, node) + require.NoError(t, err) + _, err = s.ds.FetchAttestedNode(ctx, node.SpiffeId) + require.NoError(t, err) + }) +} + +// TODO(tjons): document and justify the removal of this SQL-specific test from the shared test harness. +// func (s *PluginSuite) TestBindVar() { +// fn := func(n int) string { +// return fmt.Sprintf("$%d", n) +// } +// bound := bindVarsFn(fn, "SELECT whatever FROM foo WHERE x = ? AND y = ?") +// s.Require().Equal("SELECT whatever FROM foo WHERE x = $1 AND y = $2", bound) +// } + +func (s *PluginSuite) TestSetCAJournal() { + testCases := []struct { + name string + code codes.Code + msg string + caJournal *datastore.CAJournal + }{ + { + name: "creating a new CA journal succeeds", + caJournal: &datastore.CAJournal{ + Data: []byte("test data"), + ActiveX509AuthorityID: "x509-authority-id", + }, + }, + { + name: "nil CA journal", + code: codes.InvalidArgument, + msg: "ca journal is required", + }, + { + name: "try to update a non existing CA journal", + code: codes.NotFound, + msg: _notFoundErrMsg, + caJournal: &datastore.CAJournal{ + ID: 999, + Data: []byte("test data"), + ActiveX509AuthorityID: "x509-authority-id", + }, + }, + } + + for _, tt := range testCases { + s.T().Run(tt.name, func(t *testing.T) { + caJournal, err := s.ds.SetCAJournal(ctx, tt.caJournal) + spiretest.RequireGRPCStatus(t, err, tt.code, wrapErrMsg(tt.msg)) + if tt.code != codes.OK { + require.Nil(t, caJournal) + return + } + + assertCAJournal(t, tt.caJournal, caJournal) + }) + } +} + +func (s *PluginSuite) TestFetchCAJournal() { + testCases := []struct { + name string + activeX509AuthorityID string + code codes.Code + msg string + caJournal *datastore.CAJournal + }{ + { + name: "fetching an existent CA journal", + activeX509AuthorityID: "x509-authority-id", + caJournal: func() *datastore.CAJournal { + caJournal, err := s.ds.SetCAJournal(ctx, &datastore.CAJournal{ + ActiveX509AuthorityID: "x509-authority-id", + Data: []byte("test data"), + }) + s.Require().NoError(err) + return caJournal + }(), + }, + { + name: "non-existent X509 authority ID returns nil", + activeX509AuthorityID: "non-existent-x509-authority-id", + }, + { + name: "fetching without specifying an active authority ID fails", + code: codes.InvalidArgument, + msg: "active X509 authority ID is required", + }, + } + + for _, tt := range testCases { + s.T().Run(tt.name, func(t *testing.T) { + caJournal, err := s.ds.FetchCAJournal(ctx, tt.activeX509AuthorityID) + spiretest.RequireGRPCStatus(t, err, tt.code, wrapErrMsg(tt.msg)) + if tt.code != codes.OK { + require.Nil(t, caJournal) + return + } + + assert.Equal(t, tt.caJournal, caJournal) + }) + } +} + +func (s *PluginSuite) TestPruneCAJournal() { + now := time.Now() + t := now.Add(time.Hour) + entries := &journal.Entries{ + X509CAs: []*journal.X509CAEntry{ + { + NotAfter: t.Add(-time.Hour * 6).Unix(), + }, + }, + JwtKeys: []*journal.JWTKeyEntry{ + { + NotAfter: t.Add(time.Hour * 6).Unix(), + }, + }, + } + + entriesBytes, err := proto.Marshal(entries) + s.Require().NoError(err) + + // Store CA journal in datastore + caJournal, err := s.ds.SetCAJournal(ctx, &datastore.CAJournal{ + ActiveX509AuthorityID: "x509-authority-1", + Data: entriesBytes, + }) + s.Require().NoError(err) + + // Run a PruneCAJournals operation specifying a time that is before the + // expiration of all the authorities. The CA journal should not be pruned. + s.Require().NoError(s.ds.PruneCAJournals(ctx, t.Add(-time.Hour*12).Unix())) + caj, err := s.ds.FetchCAJournal(ctx, "x509-authority-1") + s.Require().NoError(err) + s.Require().Equal(caJournal, caj) + + // Run a PruneCAJournals operation specifying a time that is before the + // expiration of one of the authorities, but not all the authorities. + // The CA journal should not be pruned. + s.Require().NoError(s.ds.PruneCAJournals(ctx, t.Unix())) + caj, err = s.ds.FetchCAJournal(ctx, "x509-authority-1") + s.Require().NoError(err) + s.Require().Equal(caJournal, caj) + + // Run a PruneCAJournals operation specifying a time that is after the + // expiration of all the authorities. The CA journal should be pruned. + s.Require().NoError(s.ds.PruneCAJournals(ctx, t.Add(time.Hour*12).Unix())) + caj, err = s.ds.FetchCAJournal(ctx, "x509-authority-1") + s.Require().NoError(err) + s.Require().Nil(caj) +} + +// TODO(tjons): document and justify the removal of this test case +// +// It's specific to the SQL implementation and thus not relevant for testing +// the DataStore contract. +// +// func (s *PluginSuite) TestBuildQuestionsAndPlaceholders() { +// for _, tt := range []struct { +// name string +// entries []string +// expectedQuestions string +// expectedPlaceholders string +// }{ +// { +// name: "No args", +// expectedQuestions: "", +// expectedPlaceholders: "", +// }, +// { +// name: "One arg", +// entries: []string{"a"}, +// expectedQuestions: "?", +// expectedPlaceholders: "$1", +// }, +// { +// name: "Five args", +// entries: []string{"a", "b", "c", "e", "f"}, +// expectedQuestions: "?,?,?,?,?", +// expectedPlaceholders: "$1,$2,$3,$4,$5", +// }, +// } { +// s.T().Run(tt.name, func(t *testing.T) { +// questions := buildQuestions(tt.entries) +// s.Require().Equal(tt.expectedQuestions, questions) +// placeholders := buildPlaceholders(tt.entries) +// s.Require().Equal(tt.expectedPlaceholders, placeholders) +// }) +// } +// } + +func (s *PluginSuite) getTestDataFromJSON(data []byte, jsonValue any) { + err := json.Unmarshal(data, &jsonValue) + s.Require().NoError(err) +} + +func (s *PluginSuite) fetchBundle(trustDomainID string) *common.Bundle { + bundle, err := s.ds.FetchBundle(ctx, trustDomainID) + s.Require().NoError(err) + return bundle +} + +func (s *PluginSuite) createBundle(trustDomainID string) *common.Bundle { + bundle, err := s.ds.CreateBundle(ctx, bundleutil.BundleProtoFromRootCA(trustDomainID, s.cert)) + s.Require().NoError(err) + return bundle +} + +func (s *PluginSuite) createRegistrationEntry(entry *common.RegistrationEntry) *common.RegistrationEntry { + registrationEntry, err := s.ds.CreateRegistrationEntry(ctx, entry) + s.Require().NoError(err) + s.Require().NotNil(registrationEntry) + return registrationEntry +} + +func (s *PluginSuite) deleteRegistrationEntry(entryID string) { + _, err := s.ds.DeleteRegistrationEntry(ctx, entryID) + s.Require().NoError(err) +} + +func (s *PluginSuite) fetchRegistrationEntry(entryID string) *common.RegistrationEntry { + registrationEntry, err := s.ds.FetchRegistrationEntry(ctx, entryID) + s.Require().NoError(err) + s.Require().NotNil(registrationEntry) + return registrationEntry +} + +func makeFederatedRegistrationEntry() *common.RegistrationEntry { + return &common.RegistrationEntry{ + Selectors: []*common.Selector{ + {Type: "Type1", Value: "Value1"}, + }, + SpiffeId: "spiffe://example.org/foo", + FederatesWith: []string{"spiffe://otherdomain.org"}, + } +} + +func (s *PluginSuite) getNodeSelectors(spiffeID string) []*common.Selector { + selectors, err := s.ds.GetNodeSelectors(ctx, spiffeID, datastore.RequireCurrent) + s.Require().NoError(err) + return selectors +} + +func (s *PluginSuite) listNodeSelectors(req *datastore.ListNodeSelectorsRequest) *datastore.ListNodeSelectorsResponse { + resp, err := s.ds.ListNodeSelectors(ctx, req) + s.Require().NoError(err) + s.Require().NotNil(resp) + return resp +} + +// TODO(tjons): document and justify the removal of this SQL-specific test from the shared harness. +// +// +// func (s *PluginSuite) setNodeSelectors(spiffeID string, selectors []*common.Selector) { +// err := s.ds.SetNodeSelectors(ctx, spiffeID, selectors) +// s.Require().NoError(err) +// } + +// func (s *PluginSuite) TestConfigure() { +// tests := []struct { +// desc string +// giveDBConfig string +// expectMaxOpenConns int +// expectIdle int +// }{ +// { +// desc: "defaults", +// expectMaxOpenConns: 100, +// // defined in database/sql +// expectIdle: 100, +// }, +// { +// desc: "zero values", +// giveDBConfig: ` +// max_open_conns = 0 +// max_idle_conns = 0 +// `, +// expectMaxOpenConns: 0, +// expectIdle: 0, +// }, +// { +// desc: "custom values", +// giveDBConfig: ` +// max_open_conns = 1000 +// max_idle_conns = 50 +// conn_max_lifetime = "10s" +// `, +// expectMaxOpenConns: 1000, +// expectIdle: 50, +// }, +// } + +// for _, tt := range tests { +// s.T().Run(tt.desc, func(t *testing.T) { +// dbPath := filepath.ToSlash(filepath.Join(s.dir, "test-datastore-configure.sqlite3")) + +// log, _ := test.NewNullLogger() +// p := New(log) +// err := p.Configure(ctx, fmt.Sprintf(` +// database_type = "sqlite3" +// log_sql = true +// connection_string = "%s" +// %s +// `, dbPath, tt.giveDBConfig)) +// require.NoError(t, err) +// defer p.Close() + +// db := p.db.DB.DB() +// require.Equal(t, tt.expectMaxOpenConns, db.Stats().MaxOpenConnections) + +// // begin many queries simultaneously +// numQueries := 100 +// var rowsList []*sql.Rows +// for range numQueries { +// rows, err := db.Query("SELECT * FROM bundles") +// require.NoError(t, err) +// rowsList = append(rowsList, rows) +// } + +// // close all open queries, which results in idle connections +// for _, rows := range rowsList { +// require.NoError(t, rows.Close()) +// } +// require.Equal(t, tt.expectIdle, db.Stats().Idle) +// }) +// } +// } + +func (s *PluginSuite) assertEntryEqual(t *testing.T, expectEntry, createdEntry *common.RegistrationEntry, now int64) { + require.NotEmpty(t, createdEntry.EntryId) + expectEntry.EntryId = "" + createdEntry.EntryId = "" + s.assertCreatedAtField(createdEntry, now) + createdEntry.CreatedAt = expectEntry.CreatedAt + spiretest.RequireProtoEqual(t, createdEntry, expectEntry) +} + +func (s *PluginSuite) assertCreatedAtFields(result *datastore.ListRegistrationEntriesResponse, now int64) { + for _, entry := range result.Entries { + s.assertCreatedAtField(entry, now) + } +} + +func (s *PluginSuite) assertCreatedAtField(entry *common.RegistrationEntry, now int64) { + // We can't compare the exact time because we don't have control over the clock used by the database. + s.Assert().GreaterOrEqual(entry.CreatedAt, now) + entry.CreatedAt = 0 +} + +func (s *PluginSuite) checkAttestedNodeEvents(expectedEvents []datastore.AttestedNodeEvent, spiffeID string) []datastore.AttestedNodeEvent { + expectedEvents = append(expectedEvents, datastore.AttestedNodeEvent{ + EventID: uint(len(expectedEvents) + 1), + SpiffeID: spiffeID, + }) + + resp, err := s.ds.ListAttestedNodeEvents(ctx, &datastore.ListAttestedNodeEventsRequest{}) + s.Require().NoError(err) + s.Require().Equal(expectedEvents, resp.Events) + + return expectedEvents +} + +// assertBundlesEqual asserts that the two bundle lists are equal independent +// of ordering. +func assertBundlesEqual(t *testing.T, expected, actual []*common.Bundle) { + if !assert.Equal(t, len(expected), len(actual)) { + return + } + + es := map[string]*common.Bundle{} + as := map[string]*common.Bundle{} + + for _, e := range expected { + es[e.TrustDomainId] = e + } + + for _, a := range actual { + as[a.TrustDomainId] = a + } + + for id, a := range as { + e, ok := es[id] + if assert.True(t, ok, "bundle %q was unexpected", id) { + spiretest.AssertProtoEqual(t, e, a) + delete(es, id) + } + } + + for id := range es { + assert.Failf(t, "bundle %q was expected but not found", id) + } +} + +func wipePostgres(t *testing.T, connString string) { + db, err := sql.Open("postgres", connString) + require.NoError(t, err) + defer db.Close() + + rows, err := db.Query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public';`) + require.NoError(t, err) + defer rows.Close() + + dropTables(t, db, scanTableNames(t, rows)) +} + +func wipeMySQL(t *testing.T, connString string) { + db, err := sql.Open("mysql", connString) + require.NoError(t, err) + defer db.Close() + + rows, err := db.Query(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'spire';`) + require.NoError(t, err) + defer rows.Close() + + dropTables(t, db, scanTableNames(t, rows)) +} + +func scanTableNames(t *testing.T, rows *sql.Rows) []string { + var tableNames []string + for rows.Next() { + var tableName string + err := rows.Scan(&tableName) + require.NoError(t, err) + tableNames = append(tableNames, tableName) + } + require.NoError(t, rows.Err()) + return tableNames +} + +func dropTables(t *testing.T, db *sql.DB, tableNames []string) { + for _, tableName := range tableNames { + _, err := db.Exec("DROP TABLE IF EXISTS " + tableName + " CASCADE") + require.NoError(t, err) + } +} + +// assertSelectorsEqual compares two selector maps for equality +// TODO: replace this with calls to Equal when we replace common.Selector with +// a normal struct that doesn't require special comparison (i.e. not a +// protobuf) +func assertSelectorsEqual(t *testing.T, expected, actual map[string][]*common.Selector, msgAndArgs ...any) { + type selector struct { + Type string + Value string + } + convert := func(in map[string][]*common.Selector) map[string][]selector { + out := make(map[string][]selector) + for spiffeID, selectors := range in { + for _, s := range selectors { + out[spiffeID] = append(out[spiffeID], selector{Type: s.Type, Value: s.Value}) + } + } + return out + } + assert.Equal(t, convert(expected), convert(actual), msgAndArgs...) +} + +func makeSelectors(vs ...string) []*common.Selector { + var ss []*common.Selector + for _, v := range vs { + ss = append(ss, &common.Selector{Type: v, Value: v}) + } + return ss +} + +func bySelectors(match datastore.MatchBehavior, ss ...string) *datastore.BySelectors { + return &datastore.BySelectors{ + Match: match, + Selectors: makeSelectors(ss...), + } +} + +func makeID(suffix string) string { + return "spiffe://example.org/" + suffix +} + +func createBundles(t *testing.T, ds datastore.DataStore, trustDomains []string) { + for _, td := range trustDomains { + _, err := ds.CreateBundle(ctx, &common.Bundle{ + TrustDomainId: td, + RootCas: []*common.Certificate{ + { + DerBytes: []byte{1}, + }, + }, + }) + require.NoError(t, err) + } +} + +func requireURLFromString(t *testing.T, s string) *url.URL { + url, err := url.Parse(s) + if err != nil { + require.FailNow(t, err.Error()) + } + return url +} + +func assertFederationRelationship(t *testing.T, exp, actual *datastore.FederationRelationship) { + if exp == nil { + assert.Nil(t, actual) + return + } + assert.Equal(t, exp.BundleEndpointProfile, actual.BundleEndpointProfile) + assert.Equal(t, exp.BundleEndpointURL, actual.BundleEndpointURL) + assert.Equal(t, exp.EndpointSPIFFEID, actual.EndpointSPIFFEID) + assert.Equal(t, exp.TrustDomain, actual.TrustDomain) + spiretest.AssertProtoEqual(t, exp.TrustDomainBundle, actual.TrustDomainBundle) +} + +func assertCAJournal(t *testing.T, exp, actual *datastore.CAJournal) { + if exp == nil { + assert.Nil(t, actual) + return + } + assert.Equal(t, exp.ActiveX509AuthorityID, actual.ActiveX509AuthorityID) + assert.Equal(t, exp.Data, actual.Data) +} diff --git a/pkg/server/datastore/sqlstore/sqlstore.go b/pkg/server/datastore/sqlstore/sqlstore.go index f3c9d8abb7..d1bbcf3c56 100644 --- a/pkg/server/datastore/sqlstore/sqlstore.go +++ b/pkg/server/datastore/sqlstore/sqlstore.go @@ -20,13 +20,12 @@ import ( "github.com/hashicorp/hcl/hcl/printer" "github.com/jinzhu/gorm" "github.com/sirupsen/logrus" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" "github.com/spiffe/spire/pkg/common/util" "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" - configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" "github.com/spiffe/spire/pkg/common/bundleutil" - "github.com/spiffe/spire/pkg/common/catalog" "github.com/spiffe/spire/pkg/common/protoutil" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/common/x509util" @@ -150,6 +149,18 @@ func New(log logrus.FieldLogger) *Plugin { } } +func (ds *Plugin) Name() string { + return PluginName +} + +func (ds *Plugin) Type() string { + return PluginName +} + +func (ds *Plugin) GetUnderlyingDBForTesting() *sqlDB { + return ds.db +} + // CreateBundle stores the given bundle func (ds *Plugin) CreateBundle(ctx context.Context, b *common.Bundle) (bundle *common.Bundle, err error) { if err = ds.withWriteTx(ctx, func(tx *gorm.DB) (err error) { @@ -517,7 +528,7 @@ func (ds *Plugin) FetchRegistrationEntry(ctx context.Context, return nil, err } - // Return the last element in the list + // Return the last element in the list //TODO(tjons): incorrect comment return entries[entryID], nil } @@ -569,7 +580,7 @@ func (ds *Plugin) UpdateRegistrationEntry(ctx context.Context, e *common.Registr // DeleteRegistrationEntry deletes the given registration func (ds *Plugin) DeleteRegistrationEntry(ctx context.Context, entryID string, -) (registrationEntry *common.RegistrationEntry, err error) { +) (registrationEntry *common.RegistrationEntry, err error) { // consider removing the return, there is no need to return it as it is not used anywhere if err = ds.withWriteTx(ctx, func(tx *gorm.DB) (err error) { registrationEntry, err = deleteRegistrationEntry(tx, entryID) if err != nil { @@ -585,6 +596,7 @@ func (ds *Plugin) DeleteRegistrationEntry(ctx context.Context, return registrationEntry, nil } +// TODO(tjons): this is not correct // PruneRegistrationEntries takes a registration entry message, and deletes all entries which have expired // before the date in the message func (ds *Plugin) PruneRegistrationEntries(ctx context.Context, expiresBefore time.Time) (err error) { @@ -637,7 +649,7 @@ func (ds *Plugin) FetchRegistrationEntryEvent(ctx context.Context, eventID uint) } // CreateJoinToken takes a Token message and stores it -func (ds *Plugin) CreateJoinToken(ctx context.Context, token *datastore.JoinToken) (err error) { +func (ds *Plugin) CreateJoinToken(ctx context.Context, token *datastore.JoinToken) error { // (tjons): updated these bc no need to name retval if token == nil || token.Token == "" || token.Expiry.IsZero() { return errors.New("token and expiry are required") } @@ -662,7 +674,7 @@ func (ds *Plugin) FetchJoinToken(ctx context.Context, token string) (resp *datas } // DeleteJoinToken deletes the given join token -func (ds *Plugin) DeleteJoinToken(ctx context.Context, token string) (err error) { +func (ds *Plugin) DeleteJoinToken(ctx context.Context, token string) error { return ds.withWriteTx(ctx, func(tx *gorm.DB) (err error) { err = deleteJoinToken(tx, token) return err @@ -671,7 +683,7 @@ func (ds *Plugin) DeleteJoinToken(ctx context.Context, token string) (err error) // PruneJoinTokens takes a Token message, and deletes all tokens which have expired // before the date in the message -func (ds *Plugin) PruneJoinTokens(ctx context.Context, expiry time.Time) (err error) { +func (ds *Plugin) PruneJoinTokens(ctx context.Context, expiry time.Time) error { return ds.withWriteTx(ctx, func(tx *gorm.DB) (err error) { err = pruneJoinTokens(tx, expiry) return err @@ -851,28 +863,28 @@ checkAuthorities: // Configure parses HCL config payload into config struct, opens new DB based on the result, and // prunes all orphaned records -func (ds *Plugin) Configure(ctx context.Context, hclConfiguration string) error { +func (ds *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) { config := &configuration{} - if err := hcl.Decode(config, hclConfiguration); err != nil { - return err + if err := hcl.Decode(config, req.HclConfiguration); err != nil { + return nil, err } dbTypeConfig, err := parseDatabaseTypeASTNode(config.DatabaseTypeNode) if err != nil { - return err + return nil, err } config.databaseTypeConfig = dbTypeConfig if err := config.Validate(); err != nil { - return err + return nil, err } - return ds.openConnections(ctx, config) + return &configv1.ConfigureResponse{}, ds.openConnections(ctx, config) } -func (ds *Plugin) Validate(ctx context.Context, coreConfig catalog.CoreConfig, configuration string) (*configv1.ValidateResponse, error) { - config, err := buildConfig(configuration) +func (ds *Plugin) Validate(ctx context.Context, req *configv1.ValidateRequest) (*configv1.ValidateResponse, error) { + config, err := buildConfig(req.HclConfiguration) if err != nil { return &configv1.ValidateResponse{ Notes: []string{err.Error()}, @@ -1182,6 +1194,8 @@ func updateBundle(tx *gorm.DB, newBundle *common.Bundle, mask *common.BundleMask return nil, newWrappedSQLError(err) } + // TODO(tjons): I think there is a correctness bug here. + // If the mask is set, model.Data, newBundle, err = applyBundleMask(model, newBundle, mask) if err != nil { return nil, newWrappedSQLError(err) @@ -1374,6 +1388,7 @@ func countBundles(tx *gorm.DB) (int32, error) { // listBundles can be used to fetch all existing bundles. func listBundles(tx *gorm.DB, req *datastore.ListBundlesRequest) (*datastore.ListBundlesResponse, error) { + // TODO(tjons): this is not nil safe and will panic on an empty req value! if req.Pagination != nil && req.Pagination.PageSize == 0 { return nil, status.Error(codes.InvalidArgument, "cannot paginate with pagesize = 0") } @@ -1410,6 +1425,7 @@ func listBundles(tx *gorm.DB, req *datastore.ListBundlesRequest) (*datastore.Lis return nil, err } + // TODO(tjons): we can improve the performance of this by pre-allocating the length of Bundles resp.Bundles = append(resp.Bundles, bundle) } @@ -4188,7 +4204,7 @@ func pruneRegistrationEntries(tx *gorm.DB, expiresBefore time.Time, logger logru func createRegistrationEntryEvent(tx *gorm.DB, event *datastore.RegistrationEntryEvent) error { if err := tx.Create(&RegisteredEntryEvent{ - Model: Model{ + Model: Model{ //TODO(tjons): this feels wrong? ID: event.EventID, }, EntryID: event.EntryID, @@ -4761,7 +4777,7 @@ func modelToJoinToken(model JoinToken) *datastore.JoinToken { func modelToCAJournal(model CAJournal) *datastore.CAJournal { return &datastore.CAJournal{ ID: model.ID, - Data: model.Data, + Data: model.Data, // TODO(tjons): why is this not unmarshalled here? ActiveX509AuthorityID: model.ActiveX509AuthorityID, } } @@ -4780,7 +4796,7 @@ func makeFederatesWith(tx *gorm.DB, ids []string) ([]*Bundle, error) { for _, id := range ids { if !idset[id] { - return nil, fmt.Errorf("unable to find federated bundle %q", id) + return nil, fmt.Errorf("unable to find federated bundle %q", id) // Should be codes.NotFound? } } diff --git a/pkg/server/datastore/sqlstore/sqlstore_test.go b/pkg/server/datastore/sqlstore/sqlstore_test.go index f36c923f96..fb6633fe7a 100644 --- a/pkg/server/datastore/sqlstore/sqlstore_test.go +++ b/pkg/server/datastore/sqlstore/sqlstore_test.go @@ -1,5 +1,6 @@ package sqlstore +// TODO(tjons): fix missing Require() calls that allow silent failures import ( "context" "crypto/x509" @@ -8,7 +9,6 @@ import ( "errors" "fmt" "net/url" - "os" "path/filepath" "reflect" "runtime" @@ -23,12 +23,14 @@ import ( "github.com/sirupsen/logrus/hooks/test" "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" "github.com/spiffe/spire/pkg/common/bundleutil" "github.com/spiffe/spire/pkg/common/protoutil" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/common/util" "github.com/spiffe/spire/pkg/common/x509util" "github.com/spiffe/spire/pkg/server/datastore" + "github.com/spiffe/spire/pkg/server/datastore/testdata" "github.com/spiffe/spire/proto/private/server/journal" "github.com/spiffe/spire/proto/spire/common" "github.com/spiffe/spire/test/clock" @@ -129,11 +131,12 @@ func (s *PluginSuite) newPlugin() *Plugin { case "": s.nextID++ dbPath := filepath.ToSlash(filepath.Join(s.dir, fmt.Sprintf("db%d.sqlite3", s.nextID))) - err := ds.Configure(ctx, fmt.Sprintf(` + _, err := ds.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: fmt.Sprintf(` database_type = "sqlite3" log_sql = true connection_string = "%s" - `, dbPath)) + `, dbPath)}) s.Require().NoError(err) // assert that WAL journal mode is enabled @@ -153,23 +156,25 @@ func (s *PluginSuite) newPlugin() *Plugin { s.T().Logf("CONN STRING: %q", TestConnString) s.Require().NotEmpty(TestConnString, "connection string must be set") wipeMySQL(s.T(), TestConnString) - err := ds.Configure(ctx, fmt.Sprintf(` + _, err := ds.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: fmt.Sprintf(` database_type = "mysql" log_sql = true connection_string = "%s" ro_connection_string = "%s" - `, TestConnString, TestROConnString)) + `, TestConnString, TestROConnString)}) s.Require().NoError(err) case "postgres": s.T().Logf("CONN STRING: %q", TestConnString) s.Require().NotEmpty(TestConnString, "connection string must be set") wipePostgres(s.T(), TestConnString) - err := ds.Configure(ctx, fmt.Sprintf(` + _, err := ds.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: fmt.Sprintf(` database_type = "postgres" log_sql = true connection_string = "%s" ro_connection_string = "%s" - `, TestConnString, TestROConnString)) + `, TestConnString, TestROConnString)}) s.Require().NoError(err) default: s.Require().FailNowf("Unsupported external test dialect %q", TestDialect) @@ -179,10 +184,11 @@ func (s *PluginSuite) newPlugin() *Plugin { } func (s *PluginSuite) TestInvalidPluginConfiguration() { - err := s.ds.Configure(ctx, ` + _, err := s.ds.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: ` database_type = "wrong" connection_string = "bad" - `) + `}) s.RequireErrorContains(err, "datastore-sql: unsupported database_type: wrong") } @@ -209,28 +215,35 @@ func (s *PluginSuite) TestInvalidAWSConfiguration() { } for _, testCase := range testCases { s.T().Run(testCase.name, func(t *testing.T) { - err := s.ds.Configure(ctx, testCase.config) + _, err := s.ds.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: testCase.config, + }) s.RequireErrorContains(err, testCase.expectedErr) }) } } func (s *PluginSuite) TestInvalidMySQLConfiguration() { - err := s.ds.Configure(ctx, ` + _, err := s.ds.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: ` database_type = "mysql" connection_string = "username:@tcp(127.0.0.1)/spire_test" - `) + `, + }) s.RequireErrorContains(err, "datastore-sql: invalid mysql config: missing parseTime=true param in connection_string") - err = s.ds.Configure(ctx, ` + _, err = s.ds.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: fmt.Sprintf(` database_type = "mysql" ro_connection_string = "username:@tcp(127.0.0.1)/spire_test" - `) + `, TestConnString, TestROConnString)}) s.RequireErrorContains(err, "datastore-sql: connection_string must be set") - err = s.ds.Configure(ctx, ` + _, err = s.ds.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: ` database_type = "mysql" - `) + `, + }) s.RequireErrorContains(err, "datastore-sql: connection_string must be set") } @@ -1996,7 +2009,7 @@ func (s *PluginSuite) TestSetNodeSelectorsUnderLoad() { func (s *PluginSuite) TestCreateRegistrationEntry() { now := time.Now().Unix() var validRegistrationEntries []*common.RegistrationEntry - s.getTestDataFromJSONFile(filepath.Join("testdata", "valid_registration_entries.json"), &validRegistrationEntries) + s.getStaticTestData(testdata.ValidRegistrationEntries, &validRegistrationEntries) for _, validRegistrationEntry := range validRegistrationEntries { registrationEntry, err := s.ds.CreateRegistrationEntry(ctx, validRegistrationEntry) @@ -2169,7 +2182,7 @@ func (s *PluginSuite) TestCreateOrReturnRegistrationEntry() { func (s *PluginSuite) TestCreateInvalidRegistrationEntry() { var invalidRegistrationEntries []*common.RegistrationEntry - s.getTestDataFromJSONFile(filepath.Join("testdata", "invalid_registration_entries.json"), &invalidRegistrationEntries) + s.getStaticTestData(testdata.InvalidRegistrationEntries, &invalidRegistrationEntries) for _, invalidRegistrationEntry := range invalidRegistrationEntries { registrationEntry, err := s.ds.CreateRegistrationEntry(ctx, invalidRegistrationEntry) @@ -3649,7 +3662,7 @@ func (s *PluginSuite) TestDeleteRegistrationEntry() { func (s *PluginSuite) TestListParentIDEntries() { now := time.Now().Unix() allEntries := make([]*common.RegistrationEntry, 0) - s.getTestDataFromJSONFile(filepath.Join("testdata", "entries.json"), &allEntries) + s.getStaticTestData(testdata.Entries, &allEntries) tests := []struct { name string registrationEntries []*common.RegistrationEntry @@ -3692,7 +3705,7 @@ func (s *PluginSuite) TestListParentIDEntries() { func (s *PluginSuite) TestListSelectorEntries() { now := time.Now().Unix() allEntries := make([]*common.RegistrationEntry, 0) - s.getTestDataFromJSONFile(filepath.Join("testdata", "entries.json"), &allEntries) + s.getStaticTestData(testdata.Entries, &allEntries) tests := []struct { name string registrationEntries []*common.RegistrationEntry @@ -3744,7 +3757,7 @@ func (s *PluginSuite) TestListSelectorEntries() { func (s *PluginSuite) TestListEntriesBySelectorSubset() { now := time.Now().Unix() allEntries := make([]*common.RegistrationEntry, 0) - s.getTestDataFromJSONFile(filepath.Join("testdata", "entries.json"), &allEntries) + s.getStaticTestData(testdata.Entries, &allEntries) tests := []struct { name string registrationEntries []*common.RegistrationEntry @@ -3802,7 +3815,7 @@ func (s *PluginSuite) TestListEntriesBySelectorSubset() { func (s *PluginSuite) TestListSelectorEntriesSuperset() { now := time.Now().Unix() allEntries := make([]*common.RegistrationEntry, 0) - s.getTestDataFromJSONFile(filepath.Join("testdata", "entries.json"), &allEntries) + s.getStaticTestData(testdata.Entries, &allEntries) tests := []struct { name string registrationEntries []*common.RegistrationEntry @@ -3856,7 +3869,7 @@ func (s *PluginSuite) TestListSelectorEntriesSuperset() { func (s *PluginSuite) TestListEntriesBySelectorMatchAny() { now := time.Now().Unix() allEntries := make([]*common.RegistrationEntry, 0) - s.getTestDataFromJSONFile(filepath.Join("testdata", "entries.json"), &allEntries) + s.getStaticTestData(testdata.Entries, &allEntries) tests := []struct { name string registrationEntries []*common.RegistrationEntry @@ -3925,7 +3938,7 @@ func (s *PluginSuite) TestListEntriesBySelectorMatchAny() { func (s *PluginSuite) TestListEntriesByFederatesWithExact() { now := time.Now().Unix() allEntries := make([]*common.RegistrationEntry, 0) - s.getTestDataFromJSONFile(filepath.Join("testdata", "entries_federates_with.json"), &allEntries) + s.getStaticTestData(testdata.EntriesFederatesWith, &allEntries) tests := []struct { name string registrationEntries []*common.RegistrationEntry @@ -4001,7 +4014,7 @@ func (s *PluginSuite) TestListEntriesByFederatesWithExact() { func (s *PluginSuite) TestListEntriesByFederatesWithSubset() { now := time.Now().Unix() allEntries := make([]*common.RegistrationEntry, 0) - s.getTestDataFromJSONFile(filepath.Join("testdata", "entries_federates_with.json"), &allEntries) + s.getStaticTestData(testdata.EntriesFederatesWith, &allEntries) tests := []struct { name string registrationEntries []*common.RegistrationEntry @@ -4066,7 +4079,7 @@ func (s *PluginSuite) TestListEntriesByFederatesWithSubset() { func (s *PluginSuite) TestListEntriesByFederatesWithMatchAny() { now := time.Now().Unix() allEntries := make([]*common.RegistrationEntry, 0) - s.getTestDataFromJSONFile(filepath.Join("testdata", "entries_federates_with.json"), &allEntries) + s.getStaticTestData(testdata.EntriesFederatesWith, &allEntries) tests := []struct { name string registrationEntries []*common.RegistrationEntry @@ -4138,7 +4151,7 @@ func (s *PluginSuite) TestListEntriesByFederatesWithMatchAny() { func (s *PluginSuite) TestListEntriesByFederatesWithSuperset() { now := time.Now().Unix() allEntries := make([]*common.RegistrationEntry, 0) - s.getTestDataFromJSONFile(filepath.Join("testdata", "entries_federates_with.json"), &allEntries) + s.getStaticTestData(testdata.EntriesFederatesWith, &allEntries) tests := []struct { name string registrationEntries []*common.RegistrationEntry @@ -5223,10 +5236,12 @@ func (s *PluginSuite) TestMigration() { dump = minimalDB() } dumpDB(t, dbPath, dump) - err := s.ds.Configure(ctx, fmt.Sprintf(` - database_type = "sqlite3" - connection_string = %q - `, dbURI)) + _, err := s.ds.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: fmt.Sprintf(` + database_type = "sqlite3" + connection_string = %q + `, dbURI), + }) if migrationSupported { require.NoError(err) } else { @@ -5458,11 +5473,8 @@ func (s *PluginSuite) TestBuildQuestionsAndPlaceholders() { } } -func (s *PluginSuite) getTestDataFromJSONFile(filePath string, jsonValue any) { - entriesJSON, err := os.ReadFile(filePath) - s.Require().NoError(err) - - err = json.Unmarshal(entriesJSON, &jsonValue) +func (s *PluginSuite) getStaticTestData(data []byte, jsonValue any) { + err := json.Unmarshal(data, &jsonValue) s.Require().NoError(err) } @@ -5565,12 +5577,14 @@ func (s *PluginSuite) TestConfigure() { log, _ := test.NewNullLogger() p := New(log) - err := p.Configure(ctx, fmt.Sprintf(` - database_type = "sqlite3" - log_sql = true - connection_string = "%s" - %s - `, dbPath, tt.giveDBConfig)) + _, err := p.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: fmt.Sprintf(` + database_type = "sqlite3" + log_sql = true + connection_string = "%s" + %s + `, dbPath, tt.giveDBConfig), + }) require.NoError(t, err) defer p.Close() diff --git a/pkg/server/datastore/sqlstore/testdata/entries.json b/pkg/server/datastore/testdata/entries.json similarity index 100% rename from pkg/server/datastore/sqlstore/testdata/entries.json rename to pkg/server/datastore/testdata/entries.json diff --git a/pkg/server/datastore/sqlstore/testdata/entries_federates_with.json b/pkg/server/datastore/testdata/entries_federates_with.json similarity index 100% rename from pkg/server/datastore/sqlstore/testdata/entries_federates_with.json rename to pkg/server/datastore/testdata/entries_federates_with.json diff --git a/pkg/server/datastore/sqlstore/testdata/invalid_registration_entries.json b/pkg/server/datastore/testdata/invalid_registration_entries.json similarity index 100% rename from pkg/server/datastore/sqlstore/testdata/invalid_registration_entries.json rename to pkg/server/datastore/testdata/invalid_registration_entries.json diff --git a/pkg/server/datastore/testdata/testdata.go b/pkg/server/datastore/testdata/testdata.go new file mode 100644 index 0000000000..06e3c49305 --- /dev/null +++ b/pkg/server/datastore/testdata/testdata.go @@ -0,0 +1,17 @@ +package testdata + +import _ "embed" + +var ( + //go:embed entries_federates_with.json + EntriesFederatesWith []byte + + //go:embed entries.json + Entries []byte + + //go:embed invalid_registration_entries.json + InvalidRegistrationEntries []byte + + //go:embed valid_registration_entries.json + ValidRegistrationEntries []byte +) diff --git a/pkg/server/datastore/sqlstore/testdata/valid_registration_entries.json b/pkg/server/datastore/testdata/valid_registration_entries.json similarity index 100% rename from pkg/server/datastore/sqlstore/testdata/valid_registration_entries.json rename to pkg/server/datastore/testdata/valid_registration_entries.json diff --git a/pkg/server/plugin/datastore/README.md b/pkg/server/plugin/datastore/README.md new file mode 100644 index 0000000000..da2510d45d --- /dev/null +++ b/pkg/server/plugin/datastore/README.md @@ -0,0 +1,91 @@ +# Pluggable Datastore Design +This is a proof-of-concept implementation of supporting datastore plugins in the SPIRE server. It makes core changes to `spire` and `spire-plugin-sdk` to support "pluggability" of the datastore. Included is a prototype reference implementation using Apache Cassandra as a backing datastore for SPIRE server. It leverages Cassandra 5.0 and is not compatible with earlier versions of Cassandra due to heavily leveraging Storage-Attached Indexes to improve query performance. + +## Background +There have been frequent requests from the community to support customizations to the SPIRE Server storage layer, including support for additional databases and other customizations that introduce maintance burden and increase core project complexity. In other issues, changes to the datastore architecture are discussed as potential solutions to various scalability, reliability or availability problems. Some examples include: +- https://github.com/spiffe/spire/issues/5993: Requested support for Apache Cassandra (my own feature request that spawned this project) +- https://github.com/spiffe/spire/issues/5624: Using CDC with SQL to build the event-based cache +- https://github.com/spiffe/spire/issues/5501#issuecomment-3765754948: supporting larger primary key ID ranges in large scale deployments +- https://github.com/spiffe/spire/issues/5113: discussion of rotation of database credentials +- https://github.com/spiffe/spire/issues/2664: support for HA Databases via dqlite or cowsql +- https://github.com/spiffe/spire/issues/5854: support for GaussDB +- https://github.com/spiffe/spire/issues/4968: customizations to database authentication to use X509-SVIDs as authentication material +- https://github.com/spiffe/spire/issues/4992: using a multi-primary database architecture +- https://github.com/spiffe/spire/issues/4212: client certificate authentication with SPIRE-generated certificates for the database +- https://github.com/spiffe/spire/issues/1945: support for pluggable KV and SQL stores, mentioning etcd. This issue involved a full redesign of the data storage interface and was closed due to complexity. +- https://github.com/spiffe/spire/issues/1218: support for disabling auto-migrations and allowing users to migrate the database schema manually via an out-of-band process +- https://github.com/spiffe/spire/issues/1830: prototyping for using the now-defunct Cayley project for pluggability + +Based on these issues, it is clear to me that providing some mechanism to extend the data storage layer without requiring fully custom builds of the SPIRE server binary or container would be useful. Extensionability allows advanced users to make customizations that _they themselves are responsible for understanding, verifying, and supporting_. This model has made SPIRE extremely successful for supporting various Node or Workload attestation strategies, and the success of these safety and correctness-critical plugin subsystems demonstrate that even the most sensitive features of SPIRE like attestation benefit from a generic extensionability model. + +If a user today wants to customize the datastore implementation, they effectively have two options: +- fork and build SPIRE from source, introducing long-term maintance burden and overhead to sustain that fork, keep it up-to-date and verify no regressions in core functionality, etc. +- propose a change to the existing core datastore, which places the burden on SPIRE maintainers to understand and develop expertise in whatever storage architecture the user would like to use. Rightfully so, this means that many proposed datastore changes do not get made, because the complexity of maintaining and assuring the safety and correctness of many different implementations is an unreasonable burden to place on a small maintainer pool. + +Extending the SPIRE storage layer to support plugins strikes a balance between user extensibility and maintainer burden: users are free to extend the datastore as they would like, while taking on full responsibility for supportability, reliability and performance. Maintainers continue to own the core SPIRE Datastore interface layer, and support the SQL implementation currently available in the SPIRE Server. The Datastore interface can evolve along with the needs of the SPIRE project, and users will manage ensuring that their plugin implementations are up to date. Once this model is proven and mature, if desired, it may be more beneficial to consider changes to the core storage system to further reduce the maintance burden and decrease the likelyhood of breaking changes in the SPIRE server cascading to experimental plugins. There is, however, no need to do this to accomplish basic pluggability. + +This document describes the implementation decisions to support pluggability of the SPIRE datastore, using Apache Cassandra as a reference implementation. + +### About Cassandra +Apache Cassandra is a write-optimized, masterless NoSQL distributed database based around a table and wide column storage model. Cassandra offers tunable consistency at the query level, allowing developers to determine how much performance they are willing to sacrifice for strong consistency. Most queries in Cassandra are executed at some level of eventual consistency. + +Cassandra shines in providing global availability with acceptable performance on both the write and read path. Unlike traditional relational databases, Cassandra does not use a master or leader as the write node. Instead, any node may act as the coordinator for a query, and ensures that the data is written to the right members of the cluster, or retrieved from the right members of the cluster. To learn more about Apache Cassandra's design, read [the Cassandra docs](https://cassandra.apache.org/_/cassandra-basics.html). + +### Why Cassandra for SPIRE? +SPIRE server deployments have long been limited in horizontal scalability by the need to have a Postgres or MySQL cluster that is reliable enough to not lose data. In those database systems, this means geo-replication can happen passively or actively, but one cluster member in one region globally must be the writer at all times. Failing over is an event that incures some amount of downtime, unless one is willing to lose data. + +Cassandra solves these problems by allowing SPIRE servers to horizontally scale a single trust domain across regions to an unparalleled level. Cassandra is not an easy database to learn, nor an easy database to operate, but it offers outstanding performance and scalability when properly understood and deployed. My hope in sharing this implementation with the SPIRE community is to demonstrate that evolution in the data storage layer in SPIRE is needed, and that it's possible. To see more about the Cassandra plugin implemented to prove the viability of a pluggable datastore, see [the plugin design document](../../datastore/cassandra/design/README.md). + +## Design notes +The SPIRE server defines [a `Datastore` interface](https://github.com/spiffe/spire/blob/main/pkg/server/datastore/datastore.go) for data storage interactions. This package also contains a number of types for use by the SPIRE server client code when interacting with the interface, including various filters, request and response types, etc. The `datastore` package also contains a simple `Repository` type with accessor and setter methods for the datastore. The `datastore.Repository` is not used in the project and seems to be a historical artifact of previous implementation requirements that were refactored away over time. + +SPIRE Server currently has three implementations of the `datastore.Datastore` interface: +- The `sqlstore` [implementation](https://github.com/spiffe/spire/tree/main/pkg/server/datastore/sqlstore), currently the only supported datastore. +- The `pkg/common/server/datastore/` `metricsWrapper` [implementation wrapping calls to the Datastore with metrics collection](https://github.com/spiffe/spire/blob/main/pkg/common/telemetry/server/datastore/wrapper.go) +- The `test/fakes/fakedatastore` `fakeDatastore` [implementation for unit tests](https://github.com/spiffe/spire/blob/main/test/fakes/fakedatastore/fakedatastore.go). + +### The `sqlstore` implementation +The existing `Datastore` implementation is provided by the `sqlstore` package. This package uses `gorm` to wrap the underlying database, providing support for sqlite, MySQL, and Postgresql. The interface methods are implemented by the `sqlstore` package but generally delegate to private, unexported helper methods that contain the majority of the business logic. The `sqlstore` package heavily leverages transations to achieve consistency. + +### Supporting pluggability in the Datastore +A goal of this project was to explore the viability of reintroducing plugin support for the datastore used by the SPIRE server at runtime. This is not easy, as the `datastore.Datastore` interface used throughout the codebase has 56 methods and uses a mixture of its own types and other imported types in its interface. To avoid a massive distruptive refactor, I decided to leave the existing `datastore.Datastore` interface almost fully intact, with [minimal changes](#changes-made-to-the-datastore-code) made to support treating the `datastore.Datastore` interface as a plugin. + +#### `spire-api-sdk` changes +I added a `spire.plugin.server.datastore.v1alpha1` package to the `spire-api-sdk` repository. The existing API packages defined in this repository are all `v1`, and I was unable to find an example of how the SPIRE maintainers manage alpha/experimental APIs, so I followed a common Kubernetes convention. The `v1alpha1` package is large and defines a contract for implementing a datastore plugin, as well as API types for representing the types that the SPIRE server `datastore.Datastore` interface uses. This is purely a proof of concept API and deserves better documentation and scrutiny. + +The `datastorev1alpha1.Datastore` service exposes most of the same methods that the `datastore.Datastore` interface exposes. Each RPC takes a distinct `Request` message and returns a distinct `Response` message: +```proto3 +rpc AppendBundle(AppendBundleRequest) returns (AppendBundleResponse); +``` + +#### Type conversions and facade implementation +To be utilized by the SPIRE server, these types must be converted from the wire types to the types used by the `datastore.Datastore` interface. This is performed by a facade implementation, which uses a set of conversion helpers to transform each input called on the `datastore.Datastore` interface that it implements to the corresponding `datastorev1alpha1` `Request` message. Responses are then translated to the `datastore.Datastore` interface types with similar helpers. This is not the most resource-effecient way to implement this, because it introduces a per-call overhead of at least two struct initializations and corresponding wire marshalling/unmarshalling. This will require analysis and improvement over time. For now, simply expect the SPIRE server to use more memory and perform many more allocs than it does currently. + +#### Server catalog `dataStoreRepository` +A simple repository for the datastore was added, following the pattern for other plugin repositories. The datastore repository utilizes a `MaybeOne` constraint, and currently only provides the Cassandra plugin as a builtin. To mature this implementation, it would be useful to implement an additional builtin plugin, perhaps for etcd as discussed in https://github.com/spiffe/spire/issues/1945. + +#### Server catalog `ExperimentalConfig` and loading +An `ExperimentalConfig` type was added to the `pkg/server/catalog` package to allow plumbing of the experimental config option into the catalog. When the `AllowPluggableDatastore` field is set to `true`, the catalog will initialize the datastore plugin `dataStoreRepository` as a plugin repository. During `Load()` the catalog will avoid stripping the datastore configuration from other plugin configuration, and instead delegate it to the common catalog's `Load()` process. If not set, the existing code paths will be executed to strip the datastore configuration from other plugin configuration, load the SQL datastore with `loadSQLDataStore()`, and then statically set the datastore in the repository before continuing with the common catalog `Load()` process for the other plugins. + +#### Per-plugin `max_grpc_message_size` configuration option +An optional configuration field called `max_grpc_message_size` was added to the `catalog.hclPluginConfig` type. This field can be used to provide an `int` representing the maximum gRPC message size to send/receive with a plugin. This field applies to both builtin and external plugins. If not set, this field defaults to the standard 4MB gRPC maximum message size limit. This field was added due to historical context surrounding the original datastore plugin interface that mentioned issues with max message size limits breaking RPC calls to the plugin due to resource exhaustion (see https://github.com/spiffe/spire/pull/1938). + +### Changes made to the Datastore code +A number of changes were made to the datastore code, some for clarity and conciseness, some for testability, and others because changes were required to make this POC possible. + +- The `testdata` originally stored in `pkg/server/datastore/sqlstore/testdata` has been moved into an importable package leveraging Go's embedded files functionality so that the testdata can be used by multiple plugins to test the behavior of the interface contract. _This change is for conciseness and testability._ +- In the spirit of preserving the original `datastore.Datastore` interface throughout the codebase, some additional methods were added to the `datastore.Datastore` interface to support using this interface when loaded as a plugin. These include the methods listed below, and these were implemented in the three existing `datastore.Datastore` interface implementations. _These changes were **required** to support pluggability in the Datastore without requiring a major refactor to the rest of the SPIRE server code._ + - `func Name() string`: added to provide the name of the datastore when loaded as a plugin by implementing the `catalog.PluginInfo` interface LINK!!!! + - `func Type() string`: added to provide the type of the datastore implementation when loaded as a plugin. + - `func Close() error`: added to allow the datastore to be closed when loaded as a plugin. + - `func Configure (context.Context, *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error)`: added to allow the datastore to receive configuration via the standard plugin catalog loader code. This code is now used by the `dsConfigurer` type, instead of the previous wrapper methods. + - `func Validate (context.Context, *configv1.ValidateRequest) (*configv1.ValidateResponse, error)`: added to allow the datastore configuration to be validated via the standard plugin catalog loader clode. This code is also now used by the `dsConfigurer` type. +- The `datastore.Repository` type referenced in [the introduction](#design-notes) was deleted to avoid confusion between it and the new catalog `datastoreRepository`. + +Additional changes could be made to reduce the duplication and potential maintenance burden if this experimental feature is considered for inclusion: +- The `sqlstore` tests could be deduplicated to only cover test cases that are specific to the behavior of the `sqlstore` implementation, and rely on the common interface tests where possible. This change would likely be of **medium complexity**. +- The `datastore` tests should be simplified and cleaned up. There is a heavy reliance on mutations of shared objects passed by reference from the suite to the store (as an example, see `TestBundleCRUD`, where returned objects are ignored and the same object is inspected repeatedly after mutation by the datastore). This change would likely be of **high complexity**. +- Well-known errors could be defined in the plugin SDK, improving testability and providing a tighter contract between the datastore core and plugins. Currently errors are checked via string matching, grpc status checks and other approaches, which doesn't provide a tight contract for plugins and makes tests challenging to write. + +### Open questions and areas for improvement +- Closing a datastore with the plugintest closer seems to error diff --git a/pkg/server/plugin/datastore/cassandra/cassandra.go b/pkg/server/plugin/datastore/cassandra/cassandra.go new file mode 100644 index 0000000000..29cb0f1bc0 --- /dev/null +++ b/pkg/server/plugin/datastore/cassandra/cassandra.go @@ -0,0 +1,23 @@ +package cassandra + +import ( + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/common/catalog" + ds_cassandra "github.com/spiffe/spire/pkg/server/datastore/cassandra" +) + +const pluginName = "cassandra" + +// BuiltIn returns the Cassandra plugin as a built-in. +func BuiltIn() catalog.BuiltIn { + return builtin(ds_cassandra.NewPlugin()) +} + +func builtin(plugin *ds_cassandra.Plugin) catalog.BuiltIn { + return catalog.MakeBuiltIn( + pluginName, + datastorev1.DataStorePluginServer(plugin), + configv1.ConfigServiceServer(plugin), + ) +} diff --git a/pkg/server/plugin/datastore/cassandra/doc.go b/pkg/server/plugin/datastore/cassandra/doc.go new file mode 100644 index 0000000000..b35aa3250d --- /dev/null +++ b/pkg/server/plugin/datastore/cassandra/doc.go @@ -0,0 +1,2 @@ +// Package cassandra implements a plugin for the SPIRE Server that uses Cassandra as a datastore. +package cassandra diff --git a/pkg/server/plugin/datastore/conversion_utils.go b/pkg/server/plugin/datastore/conversion_utils.go new file mode 100644 index 0000000000..7d080e140a --- /dev/null +++ b/pkg/server/plugin/datastore/conversion_utils.go @@ -0,0 +1,443 @@ +package datastore + +import ( + "fmt" + "net/url" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + ds_types "github.com/spiffe/spire/pkg/server/datastore" + "github.com/spiffe/spire/proto/spire/common" + "google.golang.org/protobuf/proto" +) + +// This file contains utility functions for converting between types used in SPIRE server code +// and the wire types defined in the datastore v1alpha1 plugin SDK. +// +// These functions take the form of `fromServerToPluginX` or `fromPluginToServerX` to indicate +// which direction the conversion is going. Only required conversion functions should be implemented +// here. + +func datastoreTypeConversionError[targetType any](err error, pluginType any) error { + return fmt.Errorf("error converting between plugin type %T and server type %T: %w", pluginType, *new(targetType), err) +} + +func serverTypeConversionError[targetType any](err error, serverType any) error { + return fmt.Errorf("error converting between server type %T and plugin type %T: %w", serverType, *new(targetType), err) +} + +func fromServerToPluginBundle(bundle *common.Bundle) (*datastorev1.Bundle, error) { + if bundle == nil { + return nil, nil + } + + bundleBytes, err := proto.Marshal(bundle) + if err != nil { + return nil, serverTypeConversionError[*datastorev1.Bundle](err, bundle) + } + + return &datastorev1.Bundle{ + Data: bundleBytes, + TrustDomainId: bundle.TrustDomainId, + }, nil +} + +func fromPluginToServerBundle(bundle *datastorev1.Bundle) (*common.Bundle, error) { + if bundle == nil { + return nil, nil + } + + b := new(common.Bundle) + if err := proto.Unmarshal(bundle.Data, b); err != nil { + return nil, datastoreTypeConversionError[*common.Bundle](err, bundle) + } + + return b, nil +} + +func fromPluginToServerJwtSigningKey(datastoreJwtSigningKey *datastorev1.PublicKey) *common.PublicKey { + if datastoreJwtSigningKey == nil { + return nil + } + + return &common.PublicKey{ + Kid: datastoreJwtSigningKey.Kid, + PkixBytes: datastoreJwtSigningKey.PkixBytes, + NotAfter: datastoreJwtSigningKey.NotAfter, + TaintedKey: datastoreJwtSigningKey.TaintedKey, + } +} + +func fromPluginToServerBundles(datastoreBundles []*datastorev1.Bundle) ([]*common.Bundle, error) { + if datastoreBundles == nil { + return nil, nil + } + + commonBundles := make([]*common.Bundle, len(datastoreBundles)) + var err error + for i, eachDatastoreBundle := range datastoreBundles { + commonBundles[i], err = fromPluginToServerBundle(eachDatastoreBundle) + if err != nil { + return nil, err + } + } + + return commonBundles, nil +} + +func fromPluginToServerPagination(datastorePagination *datastorev1.Pagination) *ds_types.Pagination { + if datastorePagination == nil { + return nil + } + + return &ds_types.Pagination{ + PageSize: datastorePagination.PageSize, + Token: datastorePagination.PageToken, + } +} + +func fromServerToPluginPagination(pagination *ds_types.Pagination) *datastorev1.Pagination { + if pagination == nil { + return nil + } + + return &datastorev1.Pagination{ + PageSize: pagination.PageSize, + PageToken: pagination.Token, + } +} + +func fromServerToPluginBundleMask(bundleMask *common.BundleMask) *datastorev1.BundleMask { + if bundleMask == nil { + return nil + } + + return &datastorev1.BundleMask{ + RootCas: bundleMask.RootCas, + JwtSigningKeys: bundleMask.JwtSigningKeys, + SequenceNumber: bundleMask.SequenceNumber, + RefreshHint: bundleMask.RefreshHint, + WitSigningKeys: bundleMask.WitSigningKeys, + X509TaintedKeys: bundleMask.X509TaintedKeys, + } +} + +func fromServerToPluginDataConsistency(dataConsistency ds_types.DataConsistency) datastorev1.DataConsistency { + switch dataConsistency { + case ds_types.TolerateStale: + return datastorev1.DataConsistency_DATA_CONSISTENCY_TOLERATE_STALE + case ds_types.RequireCurrent: + return datastorev1.DataConsistency_DATA_CONSISTENCY_REQUIRE_CURRENT + default: + return datastorev1.DataConsistency_DATA_CONSISTENCY_REQUIRE_CURRENT + } +} + +func fromPluginToServerSelector(dsSelector *datastorev1.Selector) *common.Selector { + if dsSelector == nil { + return nil + } + + return &common.Selector{ + Type: dsSelector.Type, + Value: dsSelector.Value, + } +} + +func fromServerToPluginSelector(selector *common.Selector) *datastorev1.Selector { + if selector == nil { + return nil + } + + return &datastorev1.Selector{ + Type: selector.Type, + Value: selector.Value, + } +} + +func fromPluginToServerSelectors(dsSelectors []*datastorev1.Selector) []*common.Selector { + if dsSelectors == nil { + return nil + } + + commonSelectors := make([]*common.Selector, len(dsSelectors)) + for i, eachDsSelector := range dsSelectors { + commonSelectors[i] = fromPluginToServerSelector(eachDsSelector) + } + + return commonSelectors +} + +func fromServerToPluginSelectors(selectors []*common.Selector) []*datastorev1.Selector { + if selectors == nil { + return nil + } + + dsSelectors := make([]*datastorev1.Selector, len(selectors)) + for i, eachSelector := range selectors { + dsSelectors[i] = fromServerToPluginSelector(eachSelector) + } + + return dsSelectors +} + +func fromServerToPluginBySelectors(sel *ds_types.BySelectors) *datastorev1.BySelectors { + if sel == nil { + return nil + } + + selectors := make([]*datastorev1.Selector, len(sel.Selectors)) + for i, eachDsSelector := range sel.Selectors { + selectors[i] = fromServerToPluginSelector(eachDsSelector) + } + + return &datastorev1.BySelectors{ + Selectors: selectors, + MatchBehavior: datastorev1.MatchBehavior(sel.Match), + } +} + +func fromServerToPluginByFederatesWith(sel *ds_types.ByFederatesWith) *datastorev1.ByFederatesWith { + if sel == nil { + return nil + } + + return &datastorev1.ByFederatesWith{ + FederatesWith: sel.TrustDomains, + MatchBehavior: datastorev1.MatchBehavior(sel.Match), + } +} + +func fromServerToPluginRegistrationEntry(entry *common.RegistrationEntry) *datastorev1.RegistrationEntry { + if entry == nil { + return nil + } + + return &datastorev1.RegistrationEntry{ + EntryId: entry.EntryId, + ParentId: entry.ParentId, + SpiffeId: entry.SpiffeId, + Selectors: fromServerToPluginSelectors(entry.Selectors), + FederatesWith: entry.FederatesWith, + Admin: entry.Admin, + DnsNames: entry.DnsNames, + X509SvidTtl: entry.X509SvidTtl, + JwtSvidTtl: entry.JwtSvidTtl, + Downstream: entry.Downstream, + StoreSvid: entry.StoreSvid, + EntryExpiry: entry.EntryExpiry, + Hint: entry.Hint, + CreatedAt: entry.CreatedAt, + RevisionNumber: entry.RevisionNumber, + } +} + +func fromPluginToServerRegistrationEntry(entry *datastorev1.RegistrationEntry) *common.RegistrationEntry { + if entry == nil { + return nil + } + + return &common.RegistrationEntry{ + EntryId: entry.EntryId, + ParentId: entry.ParentId, + SpiffeId: entry.SpiffeId, + Selectors: fromPluginToServerSelectors(entry.Selectors), + FederatesWith: entry.FederatesWith, + Admin: entry.Admin, + DnsNames: entry.DnsNames, + X509SvidTtl: entry.X509SvidTtl, + JwtSvidTtl: entry.JwtSvidTtl, + Downstream: entry.Downstream, + StoreSvid: entry.StoreSvid, + EntryExpiry: entry.EntryExpiry, + Hint: entry.Hint, + CreatedAt: entry.CreatedAt, + RevisionNumber: entry.RevisionNumber, + } +} + +func fromPluginToServerRegisterationEntries(entries []*datastorev1.RegistrationEntry) []*common.RegistrationEntry { + if entries == nil { + return nil + } + + commonEntries := make([]*common.RegistrationEntry, len(entries)) + for i, eachEntry := range entries { + commonEntries[i] = fromPluginToServerRegistrationEntry(eachEntry) + } + + return commonEntries +} + +func fromServerToPluginRegistrationEntriesMask(mask *common.RegistrationEntryMask) *datastorev1.RegistrationEntryMask { + if mask == nil { + return nil + } + + return &datastorev1.RegistrationEntryMask{ + ParentId: mask.ParentId, + SpiffeId: mask.SpiffeId, + Selectors: mask.Selectors, + FederatesWith: mask.FederatesWith, + Admin: mask.Admin, + DnsNames: mask.DnsNames, + X509SvidTtl: mask.X509SvidTtl, + JwtSvidTtl: mask.JwtSvidTtl, + Downstream: mask.Downstream, + StoreSvid: mask.StoreSvid, + EntryExpiry: mask.EntryExpiry, + Hint: mask.Hint, + } +} + +func fromServerToPluginFederationRelationship(fr *ds_types.FederationRelationship) (*datastorev1.FederationRelationship, error) { + if fr == nil { + return nil, nil + } + + bundle, err := fromServerToPluginBundle(fr.TrustDomainBundle) + if err != nil { + return nil, err + } + bundleEndpointType, err := fromServerToPluginBundleEndpointType(fr.BundleEndpointProfile) + if err != nil { + return nil, err + } + rfr := &datastorev1.FederationRelationship{ + TrustDomainId: fr.TrustDomain.IDString(), + BundleEndpointSpiffeId: fr.EndpointSPIFFEID.String(), + TrustDomainBundle: bundle, + BundleEndpointType: bundleEndpointType, + } + + if fr.BundleEndpointURL != nil { + rfr.BundleEndpointUrl = fr.BundleEndpointURL.String() + } + + return rfr, nil +} + +func fromServerToPluginBundleEndpointType(bundleEndpointType ds_types.BundleEndpointType) (datastorev1.BundleEndpointType, error) { + switch bundleEndpointType { + case ds_types.BundleEndpointWeb: + return datastorev1.BundleEndpointType_BUNDLE_ENDPOINT_TYPE_WEB, nil + case ds_types.BundleEndpointSPIFFE: + return datastorev1.BundleEndpointType_BUNDLE_ENDPOINT_TYPE_SPIFFE, nil + default: + return 0, fmt.Errorf("unknown bundle endpoint profile type: \"%v\"", bundleEndpointType) // TODO(tjons): we should make this a lot better, this is annoying + } +} + +func fromPluginToServerBundleEndpointType(bundleEndpointType datastorev1.BundleEndpointType) ds_types.BundleEndpointType { + switch bundleEndpointType { + case datastorev1.BundleEndpointType_BUNDLE_ENDPOINT_TYPE_WEB: + return ds_types.BundleEndpointWeb + case datastorev1.BundleEndpointType_BUNDLE_ENDPOINT_TYPE_SPIFFE: + return ds_types.BundleEndpointSPIFFE + default: + return ds_types.BundleEndpointWeb + } +} + +func fromPluginToServerFederationRelationship(rfr *datastorev1.FederationRelationship) (*ds_types.FederationRelationship, error) { + if rfr == nil { + return nil, nil + } + + url, err := url.Parse(rfr.BundleEndpointUrl) + if err != nil { + return nil, serverTypeConversionError[*ds_types.FederationRelationship](err, rfr) + } + bundle, err := fromPluginToServerBundle(rfr.TrustDomainBundle) + if err != nil { + return nil, err + } + + var esi spiffeid.ID + if rfr.BundleEndpointSpiffeId != "" { + esi = spiffeid.RequireFromString(rfr.BundleEndpointSpiffeId) + } + + fr := &ds_types.FederationRelationship{ + TrustDomain: spiffeid.RequireTrustDomainFromString(rfr.TrustDomainId), + EndpointSPIFFEID: esi, + BundleEndpointURL: url, + TrustDomainBundle: bundle, + BundleEndpointProfile: fromPluginToServerBundleEndpointType(rfr.BundleEndpointType), + } + + return fr, nil +} + +func fromServerToPluginCAJournal(caJournal *ds_types.CAJournal) *datastorev1.CAJournal { + if caJournal == nil { + return nil + } + + return &datastorev1.CAJournal{ + Id: uint64(caJournal.ID), + Data: caJournal.Data, + ActiveX509AuthorityId: caJournal.ActiveX509AuthorityID, + } +} + +func fromPluginToServerCAJournal(caJournal *datastorev1.CAJournal) *ds_types.CAJournal { + if caJournal == nil { + return nil + } + + return &ds_types.CAJournal{ + ID: uint(caJournal.Id), + Data: caJournal.Data, + ActiveX509AuthorityID: caJournal.ActiveX509AuthorityId, + } +} + +func fromPluginToServerAttestedNode(attestedNode *datastorev1.AttestedNode) *common.AttestedNode { + if attestedNode == nil { + return nil + } + + return &common.AttestedNode{ + SpiffeId: attestedNode.SpiffeId, + AttestationDataType: attestedNode.AttestationDataType, + CertSerialNumber: attestedNode.CertSerialNumber, + CertNotAfter: attestedNode.CertNotAfter, + NewCertSerialNumber: attestedNode.NewCertSerialNumber, + NewCertNotAfter: attestedNode.NewCertNotAfter, + Selectors: fromPluginToServerSelectors(attestedNode.Selectors), + CanReattest: attestedNode.CanReattest, + AgentVersion: attestedNode.AgentVersion, + } +} + +func fromServerToPluginAttestedNode(attestedNode *common.AttestedNode) *datastorev1.AttestedNode { + if attestedNode == nil { + return nil + } + + return &datastorev1.AttestedNode{ + SpiffeId: attestedNode.SpiffeId, + AttestationDataType: attestedNode.AttestationDataType, + CertSerialNumber: attestedNode.CertSerialNumber, + CertNotAfter: attestedNode.CertNotAfter, + NewCertSerialNumber: attestedNode.NewCertSerialNumber, + NewCertNotAfter: attestedNode.NewCertNotAfter, + Selectors: fromServerToPluginSelectors(attestedNode.Selectors), + CanReattest: attestedNode.CanReattest, + AgentVersion: attestedNode.AgentVersion, + } +} + +func fromServerToPluginDeleteMode(mode ds_types.DeleteMode) (datastorev1.DeleteMode, error) { + switch mode { + case ds_types.Delete: + return datastorev1.DeleteMode_DELETE_MODE_DELETE, nil + case ds_types.Restrict: + return datastorev1.DeleteMode_DELETE_MODE_RESTRICT, nil + case ds_types.Dissociate: + return datastorev1.DeleteMode_DELETE_MODE_DISSOCIATE, nil + } + + return 0, fmt.Errorf("invalid delete mode: %v", mode) +} diff --git a/pkg/server/plugin/datastore/datastore.go b/pkg/server/plugin/datastore/datastore.go new file mode 100644 index 0000000000..29d886de87 --- /dev/null +++ b/pkg/server/plugin/datastore/datastore.go @@ -0,0 +1,102 @@ +package datastore + +import ( + "context" + "time" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + "github.com/spiffe/spire/pkg/common/catalog" + ds_types "github.com/spiffe/spire/pkg/server/datastore" + "github.com/spiffe/spire/proto/spire/common" +) + +// DataStore defines the data storage interface. This is the interface that plugins must implement to be used as a +// datastore plugin in SPIRE server. It is based on the datastore.Datastore type defined in the ` +// github.com/spiffe/spire/pkg/server/datastore` package, but it also includes the PluginInfo interface +// from the catalog package, which provides metadata about the plugin. +// +// It removes the Configure and Validate methods from the datastore.Datastore interface, as configuration and +// validation of plugins is now handled through the catalog.Plugin +// interface. The DataStore interface is intended to be used by plugin implementations, while the datastore.Datastore +// interface is intended to be used by the rest of the SPIRE server codebase to interact with the datastore plugin. +type DataStore interface { + catalog.PluginInfo + + // Bundles + AppendBundle(context.Context, *common.Bundle) (*common.Bundle, error) + CountBundles(context.Context) (int32, error) + CreateBundle(context.Context, *common.Bundle) (*common.Bundle, error) + DeleteBundle(ctx context.Context, trustDomainID string, mode ds_types.DeleteMode) error + FetchBundle(ctx context.Context, trustDomainID string) (*common.Bundle, error) + ListBundles(context.Context, *ds_types.ListBundlesRequest) (*ds_types.ListBundlesResponse, error) + PruneBundle(ctx context.Context, trustDomainID string, expiresBefore time.Time) (changed bool, err error) + SetBundle(context.Context, *common.Bundle) (*common.Bundle, error) + UpdateBundle(context.Context, *common.Bundle, *common.BundleMask) (*common.Bundle, error) + + // Keys + TaintX509CA(ctx context.Context, trustDomainID string, subjectKeyIDToTaint string) error + RevokeX509CA(ctx context.Context, trustDomainID string, subjectKeyIDToRevoke string) error + TaintJWTKey(ctx context.Context, trustDomainID string, authorityID string) (*common.PublicKey, error) + RevokeJWTKey(ctx context.Context, trustDomainID string, authorityID string) (*common.PublicKey, error) + + // Entries + CountRegistrationEntries(context.Context, *ds_types.CountRegistrationEntriesRequest) (int32, error) + CreateRegistrationEntry(context.Context, *common.RegistrationEntry) (*common.RegistrationEntry, error) + CreateOrReturnRegistrationEntry(context.Context, *common.RegistrationEntry) (*common.RegistrationEntry, bool, error) + DeleteRegistrationEntry(ctx context.Context, entryID string) (*common.RegistrationEntry, error) + FetchRegistrationEntry(ctx context.Context, entryID string) (*common.RegistrationEntry, error) + FetchRegistrationEntries(ctx context.Context, entryIDs []string) (map[string]*common.RegistrationEntry, error) + ListRegistrationEntries(context.Context, *ds_types.ListRegistrationEntriesRequest) (*ds_types.ListRegistrationEntriesResponse, error) + PruneRegistrationEntries(ctx context.Context, expiresBefore time.Time) error + UpdateRegistrationEntry(context.Context, *common.RegistrationEntry, *common.RegistrationEntryMask) (*common.RegistrationEntry, error) + + // Entries Events + ListRegistrationEntryEvents(ctx context.Context, req *ds_types.ListRegistrationEntryEventsRequest) (*ds_types.ListRegistrationEntryEventsResponse, error) + PruneRegistrationEntryEvents(ctx context.Context, olderThan time.Duration) error + FetchRegistrationEntryEvent(ctx context.Context, eventID uint) (*ds_types.RegistrationEntryEvent, error) + CreateRegistrationEntryEventForTesting(ctx context.Context, event *ds_types.RegistrationEntryEvent) error + DeleteRegistrationEntryEventForTesting(ctx context.Context, eventID uint) error + + // Nodes + CountAttestedNodes(context.Context, *ds_types.CountAttestedNodesRequest) (int32, error) + CreateAttestedNode(context.Context, *common.AttestedNode) (*common.AttestedNode, error) + DeleteAttestedNode(ctx context.Context, spiffeID string) (*common.AttestedNode, error) + FetchAttestedNode(ctx context.Context, spiffeID string) (*common.AttestedNode, error) + ListAttestedNodes(context.Context, *ds_types.ListAttestedNodesRequest) (*ds_types.ListAttestedNodesResponse, error) + UpdateAttestedNode(context.Context, *common.AttestedNode, *common.AttestedNodeMask) (*common.AttestedNode, error) + PruneAttestedExpiredNodes(ctx context.Context, expiredBefore time.Time, includeNonReattestable bool) error + + // Nodes Events + ListAttestedNodeEvents(ctx context.Context, req *ds_types.ListAttestedNodeEventsRequest) (*ds_types.ListAttestedNodeEventsResponse, error) + PruneAttestedNodeEvents(ctx context.Context, olderThan time.Duration) error + FetchAttestedNodeEvent(ctx context.Context, eventID uint) (*ds_types.AttestedNodeEvent, error) + CreateAttestedNodeEventForTesting(ctx context.Context, event *ds_types.AttestedNodeEvent) error + DeleteAttestedNodeEventForTesting(ctx context.Context, eventID uint) error + + // Node selectors + GetNodeSelectors(ctx context.Context, spiffeID string, dataConsistency ds_types.DataConsistency) ([]*common.Selector, error) + ListNodeSelectors(context.Context, *ds_types.ListNodeSelectorsRequest) (*ds_types.ListNodeSelectorsResponse, error) + SetNodeSelectors(ctx context.Context, spiffeID string, selectors []*common.Selector) error + + // Tokens + CreateJoinToken(context.Context, *ds_types.JoinToken) error + DeleteJoinToken(ctx context.Context, token string) error + FetchJoinToken(ctx context.Context, token string) (*ds_types.JoinToken, error) + PruneJoinTokens(context.Context, time.Time) error + + // Federation Relationships + CreateFederationRelationship(context.Context, *ds_types.FederationRelationship) (*ds_types.FederationRelationship, error) + FetchFederationRelationship(context.Context, spiffeid.TrustDomain) (*ds_types.FederationRelationship, error) + ListFederationRelationships(context.Context, *ds_types.ListFederationRelationshipsRequest) (*ds_types.ListFederationRelationshipsResponse, error) + DeleteFederationRelationship(context.Context, spiffeid.TrustDomain) error + UpdateFederationRelationship(context.Context, *ds_types.FederationRelationship, *types.FederationRelationshipMask) (*ds_types.FederationRelationship, error) + + // CA Journals + SetCAJournal(ctx context.Context, caJournal *ds_types.CAJournal) (*ds_types.CAJournal, error) + FetchCAJournal(ctx context.Context, activeX509AuthorityID string) (*ds_types.CAJournal, error) + PruneCAJournals(ctx context.Context, allCAsExpireBefore int64) error + ListCAJournalsForTesting(ctx context.Context) ([]*ds_types.CAJournal, error) + + Close() error +} diff --git a/pkg/server/plugin/datastore/doc.go b/pkg/server/plugin/datastore/doc.go new file mode 100644 index 0000000000..f25f44275f --- /dev/null +++ b/pkg/server/plugin/datastore/doc.go @@ -0,0 +1,4 @@ +// Package datastore provides the interface and facade for loading datastore plugins for the SPIRE Server. +// The datastore plugin is responsible for storing and retrieving all of the SPIRE Server's data. +// The datastore plugin can be loaded as a built-in or as an external plugin. +package datastore diff --git a/pkg/server/datastore/repository.go b/pkg/server/plugin/datastore/repository.go similarity index 64% rename from pkg/server/datastore/repository.go rename to pkg/server/plugin/datastore/repository.go index 65b5ac95e2..33f22de38e 100644 --- a/pkg/server/datastore/repository.go +++ b/pkg/server/plugin/datastore/repository.go @@ -11,3 +11,11 @@ func (repo *Repository) GetDataStore() DataStore { func (repo *Repository) SetDataStore(dataStore DataStore) { repo.DataStore = dataStore } + +func (repo *Repository) Clear() { + repo.DataStore = nil +} + +func (repo *Repository) ClearDataStore() { + repo.DataStore = nil +} diff --git a/pkg/server/plugin/datastore/v1alpha1_facade.go b/pkg/server/plugin/datastore/v1alpha1_facade.go new file mode 100644 index 0000000000..e4cc64e6ce --- /dev/null +++ b/pkg/server/plugin/datastore/v1alpha1_facade.go @@ -0,0 +1,814 @@ +package datastore + +import ( + "context" + "time" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + datastorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/datastore/v1alpha1" + "github.com/spiffe/spire/pkg/common/plugin" + ds_types "github.com/spiffe/spire/pkg/server/datastore" + "github.com/spiffe/spire/proto/spire/common" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// V1Alpha1 is a plugin facade for the v1alpha1 version of the datastore plugin API. It +// is responsible for translating between the internal SPIRE datastore types in the +// package github.com/spiffe/spire/pkg/server/datastore and the plugin wire types defined +// in the plugin SDK. +type V1Alpha1 struct { + plugin.Facade + datastorev1.DataStorePluginClient +} + +func (v1 *V1Alpha1) AppendBundle(ctx context.Context, bundle *common.Bundle) (*common.Bundle, error) { + pluginBundle, err := fromServerToPluginBundle(bundle) + if err != nil { + return nil, v1.WrapErr(err) + } + + resp, err := v1.DataStorePluginClient.AppendBundle(ctx, &datastorev1.AppendBundleRequest{ + Bundle: pluginBundle, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerBundle(resp.GetBundle()) +} + +func (v1 *V1Alpha1) CountBundles(ctx context.Context) (int32, error) { + resp, err := v1.DataStorePluginClient.CountBundles(ctx, &datastorev1.CountBundlesRequest{}) + if err != nil { + return 0, v1.WrapErr(err) + } + + return resp.GetCount(), nil +} + +func (v1 *V1Alpha1) CreateBundle(ctx context.Context, bundle *common.Bundle) (*common.Bundle, error) { + pluginBundle, err := fromServerToPluginBundle(bundle) + if err != nil { + return nil, v1.WrapErr(err) + } + + resp, err := v1.DataStorePluginClient.CreateBundle(ctx, &datastorev1.CreateBundleRequest{ + Bundle: pluginBundle, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerBundle(resp.GetBundle()) +} + +func (v1 *V1Alpha1) DeleteBundle(ctx context.Context, trustDomainID string, mode ds_types.DeleteMode) error { + pluginDeleteMode, err := fromServerToPluginDeleteMode(mode) + if err != nil { + return v1.WrapErr(err) + } + + _, err = v1.DataStorePluginClient.DeleteBundle(ctx, &datastorev1.DeleteBundleRequest{ + TrustDomain: trustDomainID, + Mode: pluginDeleteMode, + }) + return v1.WrapErr(err) +} + +func (v1 *V1Alpha1) FetchBundle(ctx context.Context, trustDomainID string) (*common.Bundle, error) { + resp, err := v1.DataStorePluginClient.FetchBundle(ctx, &datastorev1.FetchBundleRequest{ + TrustDomain: trustDomainID, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerBundle(resp.GetBundle()) +} + +func (v1 *V1Alpha1) ListBundles(ctx context.Context, req *ds_types.ListBundlesRequest) (*ds_types.ListBundlesResponse, error) { + dsReq := &datastorev1.ListBundlesRequest{ + Pagination: fromServerToPluginPagination(req.Pagination), + } + + resp, err := v1.DataStorePluginClient.ListBundles(ctx, dsReq) + if err != nil { + return nil, v1.WrapErr(err) + } + + bundles, err := fromPluginToServerBundles(resp.GetBundles()) + if err != nil { + return nil, v1.WrapErr(err) + } + + return &ds_types.ListBundlesResponse{ + Bundles: bundles, + Pagination: fromPluginToServerPagination(resp.GetPagination()), + }, nil +} + +func (v1 *V1Alpha1) PruneBundle(ctx context.Context, trustDomainID string, expiresBefore time.Time) (changed bool, err error) { + resp, err := v1.DataStorePluginClient.PruneBundle(ctx, &datastorev1.PruneBundleRequest{ + TrustDomain: trustDomainID, + ExpiresBefore: int64(expiresBefore.Unix()), + }) + if err != nil { + return false, v1.WrapErr(err) + } + + return resp.GetChanged(), nil +} + +func (v1 *V1Alpha1) SetBundle(ctx context.Context, bundle *common.Bundle) (*common.Bundle, error) { + pluginBundle, err := fromServerToPluginBundle(bundle) + if err != nil { + return nil, v1.WrapErr(err) + } + + resp, err := v1.DataStorePluginClient.SetBundle(ctx, &datastorev1.SetBundleRequest{ + Bundle: pluginBundle, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerBundle(resp.GetBundle()) +} + +func (v1 *V1Alpha1) UpdateBundle(ctx context.Context, bundle *common.Bundle, mask *common.BundleMask) (*common.Bundle, error) { + pluginBundle, err := fromServerToPluginBundle(bundle) + if err != nil { + return nil, v1.WrapErr(err) + } + + resp, err := v1.DataStorePluginClient.UpdateBundle(ctx, &datastorev1.UpdateBundleRequest{ + Bundle: pluginBundle, + Mask: fromServerToPluginBundleMask(mask), + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerBundle(resp.GetBundle()) +} + +// Keys +func (v1 *V1Alpha1) TaintX509CA(ctx context.Context, trustDomainID string, subjectKeyIDToTaint string) error { + _, err := v1.DataStorePluginClient.TaintX509CA(ctx, &datastorev1.TaintX509CARequest{ + TrustDomain: trustDomainID, + KeyId: subjectKeyIDToTaint, + }) + if err != nil { + return v1.WrapErr(err) + } + + return nil +} + +func (v1 *V1Alpha1) RevokeX509CA(ctx context.Context, trustDomainID string, subjectKeyIDToRevoke string) error { + _, err := v1.DataStorePluginClient.RevokeX509CA(ctx, &datastorev1.RevokeX509CARequest{ + TrustDomain: trustDomainID, + KeyId: subjectKeyIDToRevoke, + }) + if err != nil { + return v1.WrapErr(err) + } + + return nil +} + +func (v1 *V1Alpha1) TaintJWTKey(ctx context.Context, trustDomainID string, authorityID string) (*common.PublicKey, error) { + resp, err := v1.DataStorePluginClient.TaintJWTKey(ctx, &datastorev1.TaintJWTKeyRequest{ + TrustDomain: trustDomainID, + AuthorityId: authorityID, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerJwtSigningKey(resp.GetKey()), nil +} + +func (v1 *V1Alpha1) RevokeJWTKey(ctx context.Context, trustDomainID string, authorityID string) (*common.PublicKey, error) { + resp, err := v1.DataStorePluginClient.RevokeJWTKey(ctx, &datastorev1.RevokeJWTKeyRequest{ + TrustDomain: trustDomainID, + AuthorityId: authorityID, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerJwtSigningKey(resp.GetKey()), nil +} + +// Entries +func (v1 *V1Alpha1) CountRegistrationEntries(ctx context.Context, req *ds_types.CountRegistrationEntriesRequest) (int32, error) { + dsReq := &datastorev1.CountRegistrationEntriesRequest{ + BySelectors: fromServerToPluginBySelectors(req.BySelectors), + ByParentId: req.ByParentID, + BySpiffeId: req.BySpiffeID, + ByHint: req.ByHint, + ByFederatesWith: fromServerToPluginByFederatesWith(req.ByFederatesWith), + } + + if req.ByDownstream != nil { + dsReq.FilterByDownstream = true + dsReq.DownstreamValue = *req.ByDownstream + } + + resp, err := v1.DataStorePluginClient.CountRegistrationEntries(ctx, dsReq) + if err != nil { + return 0, v1.WrapErr(err) + } + + return resp.GetCount(), nil +} + +func (v1 *V1Alpha1) CreateRegistrationEntry(ctx context.Context, entry *common.RegistrationEntry) (*common.RegistrationEntry, error) { + resp, err := v1.DataStorePluginClient.CreateRegistrationEntry(ctx, &datastorev1.CreateRegistrationEntryRequest{ + Entry: fromServerToPluginRegistrationEntry(entry), + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerRegistrationEntry(resp.GetEntry()), nil +} + +func (v1 *V1Alpha1) CreateOrReturnRegistrationEntry(ctx context.Context, entry *common.RegistrationEntry) (*common.RegistrationEntry, bool, error) { + resp, err := v1.DataStorePluginClient.CreateOrReturnRegistrationEntry(ctx, &datastorev1.CreateOrReturnRegistrationEntryRequest{ + Entry: fromServerToPluginRegistrationEntry(entry), + }) + if err != nil { + return nil, false, v1.WrapErr(err) + } + + return fromPluginToServerRegistrationEntry(resp.GetEntry()), !resp.GetCreated(), nil +} + +func (v1 *V1Alpha1) DeleteRegistrationEntry(ctx context.Context, entryID string) (*common.RegistrationEntry, error) { + resp, err := v1.DataStorePluginClient.DeleteRegistrationEntry(ctx, &datastorev1.DeleteRegistrationEntryRequest{ + EntryId: entryID, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerRegistrationEntry(resp.GetEntry()), nil +} + +func (v1 *V1Alpha1) FetchRegistrationEntry(ctx context.Context, entryID string) (*common.RegistrationEntry, error) { + resp, err := v1.DataStorePluginClient.FetchRegistrationEntry(ctx, &datastorev1.FetchRegistrationEntryRequest{ + EntryId: entryID, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerRegistrationEntry(resp.GetEntry()), nil +} + +func (v1 *V1Alpha1) FetchRegistrationEntries(ctx context.Context, entryIDs []string) (map[string]*common.RegistrationEntry, error) { + resp, err := v1.DataStorePluginClient.FetchRegistrationEntries(ctx, &datastorev1.FetchRegistrationEntriesRequest{ + EntryIds: entryIDs, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + entryMap := make(map[string]*common.RegistrationEntry, len(resp.GetEntries())) + for _, entry := range resp.GetEntries() { + commonEntry := fromPluginToServerRegistrationEntry(entry) + entryMap[commonEntry.EntryId] = commonEntry + } + + return entryMap, nil +} + +func (v1 *V1Alpha1) ListRegistrationEntries(ctx context.Context, req *ds_types.ListRegistrationEntriesRequest) (*ds_types.ListRegistrationEntriesResponse, error) { + dsReq := &datastorev1.ListRegistrationEntriesRequest{ + BySelectors: fromServerToPluginBySelectors(req.BySelectors), + ByParentId: req.ByParentID, + BySpiffeId: req.BySpiffeID, + ByHint: req.ByHint, + ByFederatesWith: fromServerToPluginByFederatesWith(req.ByFederatesWith), + Pagination: fromServerToPluginPagination(req.Pagination), + } + + if req.ByDownstream != nil { + dsReq.FilterByDownstream = true + dsReq.DownstreamValue = *req.ByDownstream + } + + resp, err := v1.DataStorePluginClient.ListRegistrationEntries(ctx, dsReq) + if err != nil { + return nil, v1.WrapErr(err) + } + + return &ds_types.ListRegistrationEntriesResponse{ + Entries: fromPluginToServerRegisterationEntries(resp.GetEntries()), + Pagination: fromPluginToServerPagination(resp.GetPagination()), + }, nil +} + +func (v1 *V1Alpha1) PruneRegistrationEntries(ctx context.Context, expiresBefore time.Time) error { + _, err := v1.DataStorePluginClient.PruneRegistrationEntries(ctx, &datastorev1.PruneRegistrationEntriesRequest{ + ExpiresBefore: int64(expiresBefore.Unix()), + }) + return v1.WrapErr(err) +} + +func (v1 *V1Alpha1) UpdateRegistrationEntry(ctx context.Context, entry *common.RegistrationEntry, mask *common.RegistrationEntryMask) (*common.RegistrationEntry, error) { + resp, err := v1.DataStorePluginClient.UpdateRegistrationEntry(ctx, &datastorev1.UpdateRegistrationEntryRequest{ + Entry: fromServerToPluginRegistrationEntry(entry), + Mask: fromServerToPluginRegistrationEntriesMask(mask), + }) + + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerRegistrationEntry(resp.GetEntry()), nil +} + +// Entries Events +func (v1 *V1Alpha1) ListRegistrationEntryEvents(ctx context.Context, req *ds_types.ListRegistrationEntryEventsRequest) (*ds_types.ListRegistrationEntryEventsResponse, error) { + resp, err := v1.DataStorePluginClient.ListRegistrationEntryEvents(ctx, &datastorev1.ListRegistrationEntryEventsRequest{ + GreaterThanEventId: uint64(req.GreaterThanEventID), + LessThanEventId: uint64(req.LessThanEventID), + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + events := make([]ds_types.RegistrationEntryEvent, len(resp.GetEvents())) + for i, event := range resp.GetEvents() { + events[i] = ds_types.RegistrationEntryEvent{ + EventID: uint(event.GetEventId()), + EntryID: event.GetEntryId(), + } + } + + return &ds_types.ListRegistrationEntryEventsResponse{ + Events: events, + }, nil +} + +func (v1 *V1Alpha1) PruneRegistrationEntryEvents(ctx context.Context, olderThan time.Duration) error { + _, err := v1.DataStorePluginClient.PruneRegistrationEntryEvents(ctx, &datastorev1.PruneRegistrationEntryEventsRequest{ + ExpiresBefore: int64(olderThan.Seconds()), // TODO(tjons): not correct + }) + return v1.WrapErr(err) +} + +func (v1 *V1Alpha1) FetchRegistrationEntryEvent(ctx context.Context, eventID uint) (*ds_types.RegistrationEntryEvent, error) { + resp, err := v1.DataStorePluginClient.FetchRegistrationEntryEvent(ctx, &datastorev1.FetchRegistrationEntryEventRequest{ + EventId: uint64(eventID), + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return &ds_types.RegistrationEntryEvent{ + EventID: uint(resp.GetEvent().GetEventId()), + EntryID: resp.GetEvent().GetEntryId(), + }, nil +} + +func (v1 *V1Alpha1) CreateRegistrationEntryEventForTesting(ctx context.Context, event *ds_types.RegistrationEntryEvent) error { + _, err := v1.DataStorePluginClient.CreateRegistrationEntryEvent(ctx, &datastorev1.CreateRegistrationEntryEventRequest{ + Event: &datastorev1.RegistrationEntryEvent{ + EventId: uint64(event.EventID), + EntryId: event.EntryID, + }, + }) + + return v1.WrapErr(err) +} + +func (v1 *V1Alpha1) DeleteRegistrationEntryEventForTesting(ctx context.Context, eventID uint) error { + _, err := v1.DataStorePluginClient.DeleteRegistrationEntryEvent(ctx, &datastorev1.DeleteRegistrationEntryEventRequest{ + EventId: uint64(eventID), + }) + + return v1.WrapErr(err) +} + +// Nodes +func (v1 *V1Alpha1) CountAttestedNodes(ctx context.Context, req *ds_types.CountAttestedNodesRequest) (int32, error) { + dsReq := &datastorev1.CountAttestedNodesRequest{ + ByAttestationType: req.ByAttestationType, + BySelectors: fromServerToPluginBySelectors(req.BySelectorMatch), + ByExpiresBefore: int64(req.ByExpiresBefore.Unix()), + FetchSelectors: req.FetchSelectors, + } + + if req.ByCanReattest != nil { + dsReq.ByCanReattest = true + dsReq.CanReattestValue = *req.ByCanReattest + } + + if req.ByBanned != nil { + dsReq.ByBanned = true + dsReq.BannedValue = *req.ByBanned + } + + resp, err := v1.DataStorePluginClient.CountAttestedNodes(ctx, dsReq) + if err != nil { + return 0, v1.WrapErr(err) + } + + return int32(resp.GetCount()), nil +} + +func (v1 *V1Alpha1) CreateAttestedNode(ctx context.Context, node *common.AttestedNode) (*common.AttestedNode, error) { + resp, err := v1.DataStorePluginClient.CreateAttestedNode(ctx, &datastorev1.CreateAttestedNodeRequest{ + Node: fromServerToPluginAttestedNode(node), + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerAttestedNode(resp.GetNode()), nil +} + +func (v1 *V1Alpha1) DeleteAttestedNode(ctx context.Context, spiffeID string) (*common.AttestedNode, error) { + resp, err := v1.DataStorePluginClient.DeleteAttestedNode(ctx, &datastorev1.DeleteAttestedNodeRequest{ + SpiffeId: spiffeID, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerAttestedNode(resp.GetNode()), nil +} + +func (v1 *V1Alpha1) FetchAttestedNode(ctx context.Context, spiffeID string) (*common.AttestedNode, error) { + resp, err := v1.DataStorePluginClient.FetchAttestedNode(ctx, &datastorev1.FetchAttestedNodeRequest{ + SpiffeId: spiffeID, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerAttestedNode(resp.GetNode()), nil +} + +func (v1 *V1Alpha1) ListAttestedNodes(ctx context.Context, req *ds_types.ListAttestedNodesRequest) (*ds_types.ListAttestedNodesResponse, error) { + dsReq := &datastorev1.ListAttestedNodesRequest{ + ByAttestationType: req.ByAttestationType, + BySelectors: fromServerToPluginBySelectors(req.BySelectorMatch), + ByExpiresBefore: int64(req.ByExpiresBefore.Unix()), + Pagination: fromServerToPluginPagination(req.Pagination), + FetchSelectors: req.FetchSelectors, + ByValidAt: int64(req.ValidAt.Unix()), + } + + if req.ByCanReattest != nil { + dsReq.ByCanReattest = true + dsReq.CanReattestValue = *req.ByCanReattest + } + + if req.ByBanned != nil { + dsReq.ByBanned = true + dsReq.BannedValue = *req.ByBanned + } + + resp, err := v1.DataStorePluginClient.ListAttestedNodes(ctx, dsReq) + if err != nil { + return nil, v1.WrapErr(err) + } + + nodes := make([]*common.AttestedNode, len(resp.GetNodes())) + for i, node := range resp.GetNodes() { + nodes[i] = fromPluginToServerAttestedNode(node) + } + + return &ds_types.ListAttestedNodesResponse{ + Nodes: nodes, + Pagination: fromPluginToServerPagination(resp.GetPagination()), + }, nil +} + +func (v1 *V1Alpha1) UpdateAttestedNode(ctx context.Context, node *common.AttestedNode, mask *common.AttestedNodeMask) (*common.AttestedNode, error) { + req := &datastorev1.UpdateAttestedNodeRequest{ + Node: fromServerToPluginAttestedNode(node), + } + + if mask != nil { + req.Mask = &datastorev1.AttestedNodeMask{ + AttestationDataType: mask.AttestationDataType, + CertSerialNumber: mask.CertSerialNumber, + CertNotAfter: mask.CertNotAfter, + NewCertSerialNumber: mask.NewCertSerialNumber, + NewCertNotAfter: mask.NewCertNotAfter, + CanReattest: mask.CanReattest, + AgentVersion: mask.AgentVersion, + } + } + + resp, err := v1.DataStorePluginClient.UpdateAttestedNode(ctx, req) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerAttestedNode(resp.GetNode()), nil +} + +func (v1 *V1Alpha1) PruneAttestedExpiredNodes(ctx context.Context, expiredBefore time.Time, includeNonReattestable bool) error { + _, err := v1.DataStorePluginClient.PruneAttestedExpiredNodes(ctx, &datastorev1.PruneAttestedExpiredNodesRequest{ + ExpiresBefore: int64(expiredBefore.Unix()), + IncludeNonReattestable: includeNonReattestable, + }) + + return v1.WrapErr(err) +} + +// Nodes Events +func (v1 *V1Alpha1) ListAttestedNodeEvents(ctx context.Context, req *ds_types.ListAttestedNodeEventsRequest) (*ds_types.ListAttestedNodeEventsResponse, error) { + resp, err := v1.DataStorePluginClient.ListAttestedNodeEvents(ctx, &datastorev1.ListAttestedNodeEventsRequest{ + GreaterThanEventId: int64(req.GreaterThanEventID), + LessThanEventId: int64(req.LessThanEventID), + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + events := make([]ds_types.AttestedNodeEvent, len(resp.GetEvents())) + for i, event := range resp.GetEvents() { + events[i] = ds_types.AttestedNodeEvent{ + EventID: uint(event.GetEventId()), + SpiffeID: event.GetSpiffeId(), + } + } + + return &ds_types.ListAttestedNodeEventsResponse{ + Events: events, + }, nil +} + +func (v1 *V1Alpha1) PruneAttestedNodeEvents(ctx context.Context, olderThan time.Duration) error { + _, err := v1.DataStorePluginClient.PruneAttestedNodeEvents(ctx, &datastorev1.PruneAttestedNodeEventsRequest{ + OlderThan: int64(olderThan.Seconds()), + }) + return v1.WrapErr(err) +} + +func (v1 *V1Alpha1) FetchAttestedNodeEvent(ctx context.Context, eventID uint) (*ds_types.AttestedNodeEvent, error) { + resp, err := v1.DataStorePluginClient.FetchAttestedNodeEvent(ctx, &datastorev1.FetchAttestedNodeEventRequest{ + EventId: uint64(eventID), + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return &ds_types.AttestedNodeEvent{ + EventID: uint(resp.GetEvent().GetEventId()), + SpiffeID: resp.GetEvent().GetSpiffeId(), + }, nil +} + +func (v1 *V1Alpha1) CreateAttestedNodeEventForTesting(ctx context.Context, event *ds_types.AttestedNodeEvent) error { + _, err := v1.DataStorePluginClient.CreateAttestedNodeEvent(ctx, &datastorev1.CreateAttestedNodeEventRequest{ + Event: &datastorev1.AttestedNodeEvent{ + EventId: uint64(event.EventID), + SpiffeId: event.SpiffeID, + }, + }) + + return v1.WrapErr(err) +} + +func (v1 *V1Alpha1) DeleteAttestedNodeEventForTesting(ctx context.Context, eventID uint) error { + _, err := v1.DataStorePluginClient.DeleteAttestedNodeEvent(ctx, &datastorev1.DeleteAttestedNodeEventRequest{ + EventId: uint64(eventID), + }) + + return v1.WrapErr(err) +} + +// Node selectors +func (v1 *V1Alpha1) GetNodeSelectors(ctx context.Context, spiffeID string, dataConsistency ds_types.DataConsistency) ([]*common.Selector, error) { + resp, err := v1.DataStorePluginClient.GetNodeSelectors(ctx, &datastorev1.GetNodeSelectorsRequest{ + SpiffeId: spiffeID, + DataConsistency: fromServerToPluginDataConsistency(dataConsistency), + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerSelectors(resp.GetSelectors()), nil +} + +func (v1 *V1Alpha1) ListNodeSelectors(ctx context.Context, req *ds_types.ListNodeSelectorsRequest) (*ds_types.ListNodeSelectorsResponse, error) { + resp, err := v1.DataStorePluginClient.ListNodeSelectors(ctx, &datastorev1.ListNodeSelectorsRequest{ + ValidAt: int64(req.ValidAt.Unix()), + DataConsistency: datastorev1.DataConsistency(req.DataConsistency), + }) + + if err != nil { + return nil, v1.WrapErr(err) + } + + sls := make(map[string][]*common.Selector, len(resp.GetSelectors())) + for _, ns := range resp.GetSelectors() { + sls[ns.GetSpiffeId()] = fromPluginToServerSelectors(ns.GetSelectors()) + } + + return &ds_types.ListNodeSelectorsResponse{ + Selectors: sls, + }, nil +} + +func (v1 *V1Alpha1) SetNodeSelectors(ctx context.Context, spiffeID string, selectors []*common.Selector) error { + _, err := v1.DataStorePluginClient.SetNodeSelectors(ctx, &datastorev1.SetNodeSelectorsRequest{ + SpiffeId: spiffeID, + Selectors: fromServerToPluginSelectors(selectors), + }) + return v1.WrapErr(err) +} + +// Tokens +func (v1 *V1Alpha1) CreateJoinToken(ctx context.Context, token *ds_types.JoinToken) error { + _, err := v1.DataStorePluginClient.CreateJoinToken(ctx, &datastorev1.CreateJoinTokenRequest{ + Token: token.Token, + ExpiresAt: token.Expiry.Unix(), + }) + + return v1.WrapErr(err) +} + +func (v1 *V1Alpha1) DeleteJoinToken(ctx context.Context, token string) error { + _, err := v1.DataStorePluginClient.DeleteJoinToken(ctx, &datastorev1.DeleteJoinTokenRequest{ + Token: token, + }) + + return v1.WrapErr(err) +} + +func (v1 *V1Alpha1) FetchJoinToken(ctx context.Context, token string) (*ds_types.JoinToken, error) { + resp, err := v1.DataStorePluginClient.FetchJoinToken(ctx, &datastorev1.FetchJoinTokenRequest{ + Token: token, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + if resp.GetToken() == "" { + return nil, nil + } + + return &ds_types.JoinToken{ + Token: resp.GetToken(), + Expiry: time.Unix(resp.GetExpiresAt(), 0), + }, nil +} + +func (v1 *V1Alpha1) PruneJoinTokens(ctx context.Context, olderThan time.Time) error { + _, err := v1.DataStorePluginClient.PruneJoinTokens(ctx, &datastorev1.PruneJoinTokensRequest{ + ExpiresBefore: int64(olderThan.Unix()), + }) + return v1.WrapErr(err) +} + +// Federation Relationships +func (v1 *V1Alpha1) CreateFederationRelationship(ctx context.Context, fr *ds_types.FederationRelationship) (*ds_types.FederationRelationship, error) { + pluginFederationRelationship, err := fromServerToPluginFederationRelationship(fr) + if err != nil { + return nil, v1.WrapErr(err) + } + + resp, err := v1.DataStorePluginClient.CreateFederationRelationship(ctx, &datastorev1.CreateFederationRelationshipRequest{ + Relationship: pluginFederationRelationship, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerFederationRelationship(resp.GetRelationship()) +} + +func (v1 *V1Alpha1) FetchFederationRelationship(ctx context.Context, td spiffeid.TrustDomain) (*ds_types.FederationRelationship, error) { + resp, err := v1.DataStorePluginClient.FetchFederationRelationship(ctx, &datastorev1.FetchFederationRelationshipRequest{ + TrustDomainId: td.IDString(), + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerFederationRelationship(resp.GetRelationship()) +} + +func (v1 *V1Alpha1) ListFederationRelationships(ctx context.Context, req *ds_types.ListFederationRelationshipsRequest) (*ds_types.ListFederationRelationshipsResponse, error) { + resp, err := v1.DataStorePluginClient.ListFederationRelationships(ctx, &datastorev1.ListFederationRelationshipsRequest{ + Pagination: fromServerToPluginPagination(req.Pagination), + }) + if err != nil { + return nil, v1.WrapErr(err) + } + + relationships := make([]*ds_types.FederationRelationship, len(resp.GetRelationships())) + for i, r := range resp.GetRelationships() { + relationships[i], err = fromPluginToServerFederationRelationship(r) + if err != nil { + return nil, v1.WrapErr(err) + } + } + + return &ds_types.ListFederationRelationshipsResponse{ + FederationRelationships: relationships, + Pagination: fromPluginToServerPagination(resp.GetPagination()), + }, nil +} + +func (v1 *V1Alpha1) DeleteFederationRelationship(ctx context.Context, td spiffeid.TrustDomain) error { + _, err := v1.DataStorePluginClient.DeleteFederationRelationship(ctx, &datastorev1.DeleteFederationRelationshipRequest{ + TrustDomainId: td.IDString(), + }) + return v1.WrapErr(err) +} + +func (v1 *V1Alpha1) UpdateFederationRelationship(ctx context.Context, fr *ds_types.FederationRelationship, mask *types.FederationRelationshipMask) (*ds_types.FederationRelationship, error) { + pluginFederationRelationship, err := fromServerToPluginFederationRelationship(fr) + if err != nil { + return nil, status.Error(codes.InvalidArgument, v1.WrapErr(err).Error()) // TODO(tjons): align on this error wrapping! it's nice + } + + var pluginMask *datastorev1.FederationRelationshipMask + if mask != nil { + pluginMask = &datastorev1.FederationRelationshipMask{ + TrustDomainBundle: mask.TrustDomainBundle, + BundleEndpointUrl: mask.BundleEndpointUrl, + BundleEndpointProfile: mask.BundleEndpointProfile, + } + } + + resp, err := v1.DataStorePluginClient.UpdateFederationRelationship(ctx, &datastorev1.UpdateFederationRelationshipRequest{ + Relationship: pluginFederationRelationship, + Mask: pluginMask, + }) + + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerFederationRelationship(resp.GetRelationship()) +} + +// CA Journals +func (v1 *V1Alpha1) SetCAJournal(ctx context.Context, caJournal *ds_types.CAJournal) (*ds_types.CAJournal, error) { + resp, err := v1.DataStorePluginClient.SetCAJournal(ctx, &datastorev1.SetCAJournalRequest{ + Journal: fromServerToPluginCAJournal(caJournal), + }) + + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerCAJournal(resp.GetJournal()), nil +} + +func (v1 *V1Alpha1) FetchCAJournal(ctx context.Context, activeX509AuthorityID string) (*ds_types.CAJournal, error) { + resp, err := v1.DataStorePluginClient.FetchCAJournal(ctx, &datastorev1.FetchCAJournalRequest{ + ActiveX509AuthorityId: activeX509AuthorityID, + }) + + if err != nil { + return nil, v1.WrapErr(err) + } + + return fromPluginToServerCAJournal(resp.GetJournal()), nil +} + +func (v1 *V1Alpha1) PruneCAJournals(ctx context.Context, allCAsExpireBefore int64) error { + _, err := v1.DataStorePluginClient.PruneCAJournals(ctx, &datastorev1.PruneCAJournalsRequest{ + ExpiresBefore: int64(allCAsExpireBefore), + }) + + return v1.WrapErr(err) +} + +func (v1 *V1Alpha1) ListCAJournalsForTesting(ctx context.Context) ([]*ds_types.CAJournal, error) { + resp, err := v1.DataStorePluginClient.ListCAJournals(ctx, &datastorev1.ListCAJournalsRequest{}) + if err != nil { + return nil, v1.WrapErr(err) + } + + journals := make([]*ds_types.CAJournal, len(resp.GetJournals())) + for i, j := range resp.GetJournals() { + journals[i] = fromPluginToServerCAJournal(j) + } + + return journals, nil +} + +func (v1 *V1Alpha1) Close() error { + // TODO(tjons): This is a no-op right now. There is currently no method to + // close the plugin client connection as that is managed by the + // plugin closers in the catalog. + // + // It's required to implement the ds_core.Datastore interface. + return nil +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 1ccdd2e9a5..90da587e55 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -316,6 +316,9 @@ func (s *Server) loadCatalog(ctx context.Context, metrics telemetry.Metrics, ide IdentityProvider: identityProvider, AgentStore: agentStore, HealthChecker: healthChecker, + Experimental: catalog.ExperimentalConfig{ + AllowPluggableDatastore: s.config.ExperimentalAllowPluggableDatastore, + }, }) } diff --git a/test/fakes/fakedatastore/fakedatastore.go b/test/fakes/fakedatastore/fakedatastore.go index 33a56a276d..8f0803db5c 100644 --- a/test/fakes/fakedatastore/fakedatastore.go +++ b/test/fakes/fakedatastore/fakedatastore.go @@ -14,6 +14,7 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" "github.com/spiffe/spire/pkg/common/util" "github.com/spiffe/spire/pkg/server/datastore" sql "github.com/spiffe/spire/pkg/server/datastore/sqlstore" @@ -41,10 +42,12 @@ func New(tb testing.TB) *DataStore { dbPath := filepath.Join(tmpDir, "spire.db") dbPath = url.PathEscape(dbPath) - err := ds.Configure(ctx, fmt.Sprintf(` + _, err := ds.Configure(ctx, &configv1.ConfigureRequest{ + HclConfiguration: fmt.Sprintf(` database_type = "sqlite3" connection_string = "file:%s" - `, dbPath)) + `, dbPath), + }) require.NoError(tb, err) tb.Cleanup(func() { @@ -56,6 +59,26 @@ func New(tb testing.TB) *DataStore { } } +func (s *DataStore) Name() string { + return s.ds.Name() +} + +func (s *DataStore) Type() string { + return s.ds.Type() +} + +func (s *DataStore) Configure(ctx context.Context, _ *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) { + return nil, nil // This is intentionally a no-op for the fake datastore +} + +func (s *DataStore) Validate(ctx context.Context, _ *configv1.ValidateRequest) (*configv1.ValidateResponse, error) { + return nil, nil // This is intentionally a no-op for the fake datastore +} + +func (s *DataStore) Close() error { + return s.ds.Close() +} + func (s *DataStore) CreateBundle(ctx context.Context, bundle *common.Bundle) (*common.Bundle, error) { if err := s.getNextError(); err != nil { return nil, err diff --git a/test/fakes/fakeservercatalog/catalog.go b/test/fakes/fakeservercatalog/catalog.go index f2bf63547c..309567ad15 100644 --- a/test/fakes/fakeservercatalog/catalog.go +++ b/test/fakes/fakeservercatalog/catalog.go @@ -1,9 +1,9 @@ package fakeservercatalog import ( - "github.com/spiffe/spire/pkg/server/datastore" "github.com/spiffe/spire/pkg/server/plugin/bundlepublisher" "github.com/spiffe/spire/pkg/server/plugin/credentialcomposer" + "github.com/spiffe/spire/pkg/server/plugin/datastore" "github.com/spiffe/spire/pkg/server/plugin/keymanager" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor" "github.com/spiffe/spire/pkg/server/plugin/notifier" diff --git a/test/integration/.env b/test/integration/.env new file mode 100644 index 0000000000..96d54b56ed --- /dev/null +++ b/test/integration/.env @@ -0,0 +1,4 @@ +HTTP_PROXY= +HTTPS_PROXY= +http_proxy= +https_proxy= \ No newline at end of file diff --git a/test/integration/cassandra-suites/admin-endpoints/00-setup b/test/integration/cassandra-suites/admin-endpoints/00-setup new file mode 100755 index 0000000000..41be6275e4 --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/00-setup @@ -0,0 +1,7 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/domain-a/server conf/domain-a/agent +"${ROOTDIR}/setup/x509pop/setup.sh" conf/domain-b/server conf/domain-b/agent + +"${ROOTDIR}/setup/adminclient/build.sh" "${RUNDIR}/conf/domain-a/agent/adminclient" +"${ROOTDIR}/setup/adminclient/build.sh" "${RUNDIR}/conf/domain-b/agent/adminclient" diff --git a/test/integration/cassandra-suites/admin-endpoints/01-start-server b/test/integration/cassandra-suites/admin-endpoints/01-start-server new file mode 100755 index 0000000000..b8dc9abf26 --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server-a spire-server-b diff --git a/test/integration/cassandra-suites/admin-endpoints/02-bootstrap-federation-bundles b/test/integration/cassandra-suites/admin-endpoints/02-bootstrap-federation-bundles new file mode 100755 index 0000000000..94f3406cd1 --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/02-bootstrap-federation-bundles @@ -0,0 +1,13 @@ +#!/bin/bash + +log-debug "bootstrapping bundle from server b to server a..." +docker compose exec -T spire-server-b \ + /opt/spire/bin/spire-server bundle show -format spiffe \ +| docker compose exec -T spire-server-a \ + /opt/spire/bin/spire-server bundle set -format spiffe -id spiffe://domain-b.test + +log-debug "bootstrapping bundle from server a to server b..." +docker compose exec -T spire-server-a \ + /opt/spire/bin/spire-server bundle show -format spiffe \ +| docker compose exec -T spire-server-b \ + /opt/spire/bin/spire-server bundle set -format spiffe -id spiffe://domain-a.test diff --git a/test/integration/cassandra-suites/admin-endpoints/03-bootstrap-agent b/test/integration/cassandra-suites/admin-endpoints/03-bootstrap-agent new file mode 100755 index 0000000000..7661d79c8f --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/03-bootstrap-agent @@ -0,0 +1,9 @@ +#!/bin/bash + +log-debug "bootstrapping agent a..." +docker compose exec -T spire-server-a \ + /opt/spire/bin/spire-server bundle show > conf/domain-a/agent/bootstrap.crt + +log-debug "bootstrapping agent b..." +docker compose exec -T spire-server-b \ + /opt/spire/bin/spire-server bundle show > conf/domain-b/agent/bootstrap.crt diff --git a/test/integration/cassandra-suites/admin-endpoints/04-start-agent b/test/integration/cassandra-suites/admin-endpoints/04-start-agent new file mode 100755 index 0000000000..8c235d0797 --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/04-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent-a spire-agent-b diff --git a/test/integration/cassandra-suites/admin-endpoints/05-create-registration-entries b/test/integration/cassandra-suites/admin-endpoints/05-create-registration-entries new file mode 100755 index 0000000000..589304e608 --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/05-create-registration-entries @@ -0,0 +1,30 @@ +#!/bin/bash + +log-debug "creating admin registration entry on server a..." +docker compose exec -T spire-server-a \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain-a.test/spire/agent/x509pop/$(fingerprint conf/domain-a/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain-a.test/admin" \ + -selector "unix:uid:1001" \ + -admin \ + -x509SVIDTTL 0 +check-synced-entry "spire-agent-a" "spiffe://domain-a.test/admin" + +log-debug "creating foreign admin registration entry..." +docker compose exec -T spire-server-b \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain-b.test/spire/agent/x509pop/$(fingerprint conf/domain-b/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain-b.test/admin" \ + -selector "unix:uid:1003" \ + -federatesWith "spiffe://domain-a.test" \ + -x509SVIDTTL 0 +check-synced-entry "spire-agent-b" "spiffe://domain-b.test/admin" + +log-debug "creating regular registration entry..." +docker compose exec -T spire-server-a \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain-a.test/spire/agent/x509pop/$(fingerprint conf/domain-a/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain-a.test/workload" \ + -selector "unix:uid:1002" \ + -x509SVIDTTL 0 +check-synced-entry "spire-agent-a" "spiffe://domain-a.test/workload" diff --git a/test/integration/cassandra-suites/admin-endpoints/06-test-endpoints b/test/integration/cassandra-suites/admin-endpoints/06-test-endpoints new file mode 100755 index 0000000000..42c0400035 --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/06-test-endpoints @@ -0,0 +1,13 @@ +#!/bin/bash + +log-debug "test admin workload..." +docker compose exec -u 1001 -T spire-agent-a \ + /opt/spire/conf/agent/adminclient -trustDomain domain-a.test -serverAddr spire-server-a:8081 || fail-now "failed to check admin endpoints" + +log-debug "test foreign admin workload..." +docker compose exec -u 1003 -T spire-agent-b \ + /opt/spire/conf/agent/adminclient -trustDomain domain-a.test -serverAddr spire-server-a:8081 || fail-now "failed to check admin foreign td endpoints" + +log-debug "test regular workload..." +docker compose exec -u 1002 -T spire-agent-a \ + /opt/spire/conf/agent/adminclient -trustDomain domain-a.test -serverAddr spire-server-a:8081 -expectErrors || fail-now "failed to check admin endpoints" diff --git a/test/integration/cassandra-suites/admin-endpoints/README.md b/test/integration/cassandra-suites/admin-endpoints/README.md new file mode 100644 index 0000000000..1a021f1cef --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/README.md @@ -0,0 +1,5 @@ +# Admin Endpoints Suite + +## Description + +This suite validates the server endpoints that require an admin X509-SVID diff --git a/test/integration/cassandra-suites/admin-endpoints/conf/domain-a/agent/agent.conf b/test/integration/cassandra-suites/admin-endpoints/conf/domain-a/agent/agent.conf new file mode 100644 index 0000000000..9f4e5a97d4 --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/conf/domain-a/agent/agent.conf @@ -0,0 +1,26 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server-a" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain-a.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/admin-endpoints/conf/domain-a/server/server.conf b/test/integration/cassandra-suites/admin-endpoints/conf/domain-a/server/server.conf new file mode 100644 index 0000000000..38d18ba745 --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/conf/domain-a/server/server.conf @@ -0,0 +1,61 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain-a.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + admin_ids = ["spiffe://domain-b.test/admin"] + federation { + bundle_endpoint { + address = "0.0.0.0" + port = 8082 + } + federates_with "domain-b.test" { + bundle_endpoint_url = "https://spire-server-b:8082" + bundle_endpoint_profile "https_spiffe" { + endpoint_spiffe_id = "spiffe://domain-b.test/spire/server" + } + } + } + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/admin-endpoints/conf/domain-b/agent/agent.conf b/test/integration/cassandra-suites/admin-endpoints/conf/domain-b/agent/agent.conf new file mode 100644 index 0000000000..2c13a649ef --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/conf/domain-b/agent/agent.conf @@ -0,0 +1,26 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server-b" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain-b.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/admin-endpoints/conf/domain-b/server/server.conf b/test/integration/cassandra-suites/admin-endpoints/conf/domain-b/server/server.conf new file mode 100644 index 0000000000..7b91245b79 --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/conf/domain-b/server/server.conf @@ -0,0 +1,60 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain-b.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + federation { + bundle_endpoint { + address = "0.0.0.0" + port = 8082 + } + federates_with "domain-a.test" { + bundle_endpoint_url = "https://spire-server-a:8082" + bundle_endpoint_profile "https_spiffe" { + endpoint_spiffe_id = "spiffe://domain-a.test/spire/server" + } + } + } + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire1" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/admin-endpoints/docker-compose.yaml b/test/integration/cassandra-suites/admin-endpoints/docker-compose.yaml new file mode 100644 index 0000000000..75b8265bec --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/docker-compose.yaml @@ -0,0 +1,39 @@ +services: + spire-server-a: + image: spire-server:latest-local + hostname: spire-server + env_file: + - ../.env + volumes: + - ./conf/domain-a/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent-a: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent + depends_on: [ "spire-server-a" ] + volumes: + - ./conf/domain-a/agent:/opt/spire/conf/agent + command: [ "-config", "/opt/spire/conf/agent/agent.conf" ] + spire-server-b: + image: spire-server:latest-local + env_file: + - ../.env + hostname: spire-server-foreign-td + volumes: + - ./conf/domain-b/server:/opt/spire/conf/server + command: [ "-config", "/opt/spire/conf/server/server.conf" ] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent-b: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent-foreign-td + depends_on: [ "spire-server-b" ] + volumes: + - ./conf/domain-b/agent:/opt/spire/conf/agent + command: [ "-config", "/opt/spire/conf/agent/agent.conf" ] diff --git a/test/integration/cassandra-suites/admin-endpoints/teardown b/test/integration/cassandra-suites/admin-endpoints/teardown new file mode 100755 index 0000000000..5b83d48bbe --- /dev/null +++ b/test/integration/cassandra-suites/admin-endpoints/teardown @@ -0,0 +1,8 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down + +docker exec cassandra-1 cqlsh localhost 9044 -e "DROP KEYSPACE IF EXISTS spire1;" \ No newline at end of file diff --git a/test/integration/cassandra-suites/agent-cli/00-setup b/test/integration/cassandra-suites/agent-cli/00-setup new file mode 100755 index 0000000000..c1fb18218e --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/00-setup @@ -0,0 +1,6 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent + +"${ROOTDIR}/setup/debugserver/build.sh" "${RUNDIR}/conf/server/debugclient" +"${ROOTDIR}/setup/debugagent/build.sh" "${RUNDIR}/conf/agent/debugclient" diff --git a/test/integration/cassandra-suites/agent-cli/01-start-server b/test/integration/cassandra-suites/agent-cli/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/agent-cli/02-bootstrap-agent b/test/integration/cassandra-suites/agent-cli/02-bootstrap-agent new file mode 100755 index 0000000000..8ee7d32c26 --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/02-bootstrap-agent @@ -0,0 +1,5 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt diff --git a/test/integration/cassandra-suites/agent-cli/03-start-agent b/test/integration/cassandra-suites/agent-cli/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/agent-cli/04-check-healthy b/test/integration/cassandra-suites/agent-cli/04-check-healthy new file mode 100755 index 0000000000..cfee7f89ae --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/04-check-healthy @@ -0,0 +1,22 @@ +#!/bin/bash + +RETRIES=20 +for ((m=1;m<=$RETRIES;m++)); do + AGENTS=$(docker compose exec -T spire-server /opt/spire/bin/spire-server agent list) + if [ "$AGENTS" == "No attested agents found" ]; then + continue + fi + + if ! docker compose exec -T spire-agent /opt/spire/bin/spire-agent healthcheck; then + continue + fi + + log-info "Checking for healthcheck failure with invalid path." + if docker compose exec -T spire-agent /opt/spire/bin/spire-agent healthcheck -socketPath invalid/path 2>&1; then + continue + fi + + exit 0 +done + +fail-now "Agent not found or healthcheck failed." diff --git a/test/integration/cassandra-suites/agent-cli/05-check-valid-config b/test/integration/cassandra-suites/agent-cli/05-check-valid-config new file mode 100755 index 0000000000..36d1e2a90b --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/05-check-valid-config @@ -0,0 +1,24 @@ +#!/bin/bash + +VALID_CONFIG=0 +INVALID_CONFIG=0 + +# Assert that 'validate' command works +VALIDATE=$(docker compose exec -T spire-agent /opt/spire/bin/spire-agent validate) + +# Assert that 'validate' command fails with an invalid path +VALIDATE_FAIL=$(docker compose exec -T spire-agent /opt/spire/bin/spire-agent validate -config invalid/path 2>&1 &) + +if [[ "$VALIDATE" =~ "SPIRE agent configuration file is valid." ]]; then + VALID_CONFIG=1 +fi + +if [[ "$VALIDATE_FAIL" =~ "SPIRE agent configuration file is invalid" ]]; then + INVALID_CONFIG=1 +fi + +if [ $VALID_CONFIG -eq 1 ] && [ $INVALID_CONFIG -eq 1 ]; then + exit 0 +else + exit 1 +fi diff --git a/test/integration/cassandra-suites/agent-cli/06-check-api-watch-fail b/test/integration/cassandra-suites/agent-cli/06-check-api-watch-fail new file mode 100755 index 0000000000..1f651ba396 --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/06-check-api-watch-fail @@ -0,0 +1,32 @@ +#!/bin/bash + +SVID_RECEIVED=1 +TIMEOUT_REACHED=0 + +# Run the background process and store its output in a temporary file +(docker compose exec -u 1001 -T spire-agent /opt/spire/bin/spire-agent api watch < /dev/null > api_watch_output.txt) & + +# Get the PID of the last background process +API_WATCH_PID=$! + +# Continuously check the output file for the desired pattern with a timeout of 20 seconds +TIMEOUT=20 +START_TIME=$(date +%s) +while ! grep -q "Received 1 svid after" api_watch_output.txt; do + CURRENT_TIME=$(date +%s) + ELAPSED_TIME=$((CURRENT_TIME - START_TIME)) + if [ $ELAPSED_TIME -gt $TIMEOUT ]; then + echo "Timeout reached while waiting for 'Received' message, as expected" + TIMEOUT_REACHED=1 + break + fi + sleep 1 # Wait for 1 second before checking again +done + +# If timeout is reached, the test was succesful +if [ $TIMEOUT_REACHED -eq 1 ]; then + kill -9 $API_WATCH_PID # If timeout reached, kill the background process + exit 0 +fi + +exit 1 diff --git a/test/integration/cassandra-suites/agent-cli/07-check-api-watch b/test/integration/cassandra-suites/agent-cli/07-check-api-watch new file mode 100755 index 0000000000..c7846bbc93 --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/07-check-api-watch @@ -0,0 +1,66 @@ +#!/bin/bash + +TIMEOUT_REACHED=0 +RETRIES=3 + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload-$m" \ + -selector "unix:uid:1001" \ + -x509SVIDTTL 20 & + +# Get the PID of the last background process +API_WATCH_PID=$! + +# Run the background process and store its output in a temporary file +(docker compose exec -u 1001 -T spire-agent /opt/spire/bin/spire-agent api watch < /dev/null > api_watch_output.txt) & + +# Wait for the background process to complete +wait $API_WATCH_PID + + +# Continuously check the output file for the desired pattern with a timeout of 20 seconds +# Here we just care about the first one received + +TIMEOUT=20 +START_TIME=$(date +%s) +while ! grep -q "Received 1 svid after" api_watch_output.txt; do + CURRENT_TIME=$(date +%s) + ELAPSED_TIME=$((CURRENT_TIME - START_TIME)) + if [ $ELAPSED_TIME -gt $TIMEOUT ]; then + echo "Error: Timeout reached while waiting for 'Received' message." + TIMEOUT_REACHED=1 + break + fi + sleep 1 # Wait for 1 second before checking again +done + +if [ $TIMEOUT_REACHED -eq 1 ]; then + exit 1 +fi + +# Continuously check the output file for the desired pattern with a timeout of 60 seconds +# Here we care about the number of SVID received + +TIMEOUT=60 +START_TIME=$(date +%s) +while true; do + CURRENT_TIME=$(date +%s) + ELAPSED_TIME=$((CURRENT_TIME - START_TIME)) + if [ $ELAPSED_TIME -gt $TIMEOUT ]; then + fail-now "Timeout reached while waiting for 'Received' message." + fi + + # Count the number of SVID received + COUNT_NOW=$(grep -c "Received 1 svid after" api_watch_output.txt) + + if [ $COUNT_NOW -gt 4 ]; then + echo "SVID rotated more than 4 times" + break + fi + sleep 1 # Wait for 1 second before checking again +done + +# SVID rotated more than 4 times +exit 0 diff --git a/test/integration/cassandra-suites/agent-cli/README.md b/test/integration/cassandra-suites/agent-cli/README.md new file mode 100644 index 0000000000..56188cd6c9 --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/README.md @@ -0,0 +1,5 @@ +# Agent CLI commands + +## Description + +This suite validates Agent CLI commands. diff --git a/test/integration/cassandra-suites/agent-cli/conf/agent/agent.conf b/test/integration/cassandra-suites/agent-cli/conf/agent/agent.conf new file mode 100644 index 0000000000..44085a50e4 --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/conf/agent/agent.conf @@ -0,0 +1,28 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + socket_path = "/tmp/spire-agent/public/api.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" + admin_socket_path = "/opt/debug.sock" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/agent-cli/conf/server/server.conf b/test/integration/cassandra-suites/agent-cli/conf/server/server.conf new file mode 100644 index 0000000000..2b324efd99 --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/conf/server/server.conf @@ -0,0 +1,48 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/agent-cli/docker-compose.yaml b/test/integration/cassandra-suites/agent-cli/docker-compose.yaml new file mode 100644 index 0000000000..e5e3793fb0 --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/docker-compose.yaml @@ -0,0 +1,21 @@ +services: + spire-server: + image: spire-server:latest-local + hostname: spire-server + env_file: + - ../.env + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + hostname: spire-agent + env_file: + - ../.env + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + diff --git a/test/integration/cassandra-suites/agent-cli/teardown b/test/integration/cassandra-suites/agent-cli/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/agent-cli/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/datastore-cassandra-focus/.env b/test/integration/cassandra-suites/datastore-cassandra-focus/.env new file mode 100644 index 0000000000..96d54b56ed --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-focus/.env @@ -0,0 +1,4 @@ +HTTP_PROXY= +HTTPS_PROXY= +http_proxy= +https_proxy= \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-focus/README.md b/test/integration/cassandra-suites/datastore-cassandra-focus/README.md new file mode 100644 index 0000000000..378db89863 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-focus/README.md @@ -0,0 +1,58 @@ +# Developer tooling for running Cassandra datastore tests + +The scripts and compose file here can be used to run a single Cassandra datastore test or the whole suite via Visual +Studio Code's launch/debug tooling. + +Add the following to `.vscode/tasks.json`: +```json +{ + "tasks": [ + { + "label": "start cassandra", + "type": "shell", + "command": "bash", + "args": [ + "${workspaceFolder}/test/integration/cassandra-suites/datastore-cassandra-focus/extras/debug-setup.sh" + ] + }, + { + "label": "stop cassandra", + "type": "shell", + "command": "bash", + "args": [ + "${workspaceFolder}/test/integration/cassandra-suites/datastore-cassandra-focus/extras/debug-teardown.sh" + ] + } + ] +} +``` + +Add the following to as a launch configuration in `.vscode/launch.json`: +```json +{ + "name": "Debug Cassandra integration tests", + "type": "go", + "request": "launch", + "mode": "test", + "program": "${workspaceFolder}/pkg/server/datastore", + "buildFlags": [ + "-ldflags", + "-X github.com/spiffe/spire/pkg/server/datastore_test.TestDialect=cassandra -X github.com/spiffe/spire/pkg/server/datastore_test.TestConnString=[\"localhost:9053\"];spire_test" + ], + "cwd": "${workspaceFolder}/pkg/server/datastore", + "args": [ + "-test.parallel", + "1", + "-test.v", + // "-testify.m", + // "^(TestPruneAttestedNodeEvents)$" + ], + "preLaunchTask":"start cassandra", + "postDebugTask": "stop cassandra", +} +``` + +If you'd like to run a single test, uncomment the `"-testify.m"` arg and the `"^(TestPruneAttestedNodeEvents)$"` arg. +Replace `TestPruneAttestedNodeEvents` with whatever test function you would like to run. + +Happy debugging! \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-focus/docker-compose.yaml b/test/integration/cassandra-suites/datastore-cassandra-focus/docker-compose.yaml new file mode 100644 index 0000000000..cc40785c12 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-focus/docker-compose.yaml @@ -0,0 +1,30 @@ +services: + cassandra-5: + container_name: cassandra-5 + image: cassandra:5 + ports: + - "9053:9042" + env_file: + - ./.env + environment: + - CASSANDRA_RPC_ADDRESS=0.0.0.0 + - CASSANDRA_CLUSTER_NAME=spire + - CASSANDRA_SNITCH=GossipingPropertyFileSnitch + - HEAP_NEWSIZE=512M + - MAX_HEAP_SIZE=4096M + - CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch + - CASSANDRA_DC=localdev + volumes: + - cassandra-integtest-node-1:/var/lib/cassandra:rw + restart: + on-failure + healthcheck: + test: ["CMD-SHELL", "nodetool status"] + interval: 2m + start_period: 2m + timeout: 10s + retries: 3 + + +volumes: + cassandra-integtest-node-1: \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-focus/extras/debug-setup.sh b/test/integration/cassandra-suites/datastore-cassandra-focus/extras/debug-setup.sh new file mode 100755 index 0000000000..d05c2c20a9 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-focus/extras/debug-setup.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +cd "${DIR}/.." + +docker compose up -d cassandra-5 + +MAXCHECKS=40 +CHECKINTERVAL=3 +READY= +for ((i=1;i<=MAXCHECKS;i++)); do + echo "waiting for cassandra-5 ($i of $MAXCHECKS max)..." + if docker compose exec -T "cassandra-5" nodetool status >/dev/null; then + READY=1 + break + fi + sleep "${CHECKINTERVAL}" +done + +if [ -z ${READY} ]; then + echo "timed out waiting for cassandra-5 to be ready" + exit 1 +fi + +exit 0 diff --git a/test/integration/cassandra-suites/datastore-cassandra-focus/extras/debug-teardown.sh b/test/integration/cassandra-suites/datastore-cassandra-focus/extras/debug-teardown.sh new file mode 100644 index 0000000000..9c637b925c --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-focus/extras/debug-teardown.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +cd "${DIR}/.." + +docker compose down -v cassandra-5 + +exit 0 diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/00-setup b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/00-setup new file mode 100755 index 0000000000..feb3e56526 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/00-setup @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +PKGDIR="${REPODIR}/pkg/server/datastore" + +log-debug "building cassandra test harness..." +(cd "${PKGDIR}"; go test -c -o "${DIR}"/cassandra.test -ldflags '-X github.com/spiffe/spire/pkg/server/datastore_test.TestDialect=cassandra -X github.com/spiffe/spire/pkg/server/datastore_test.TestConnString=["localhost:9044","localhost:9045","localhost:9046"];spire_test') + +log-debug "copying over test data..." +cp -r "${PKGDIR}"/testdata . diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/01-test-variants b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/01-test-variants new file mode 100755 index 0000000000..8e9c2df194 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/01-test-variants @@ -0,0 +1,53 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +docker-compose-up() { + if [ $# -eq 0 ]; then + log-debug "bringing up services..." + else + log-debug "bringing up $*..." + fi + docker compose -f "${DIR}/docker-compose.3-node-3-dc.yaml" up -d "$@" || fail-now "failed to bring up services." +} + +cassandra-up() { + SERVICE=$1 + + docker-compose-up "${SERVICE}" + + # Wait up to two minutes for each Cassandra node to be available. It should come up + # pretty quick on developer machines but CI/CD is slow. + MAXCHECKS=40 + CHECKINTERVAL=3 + READY= + for ((i=1;i<=MAXCHECKS;i++)); do + log-info "waiting for ${SERVICE} ($i of $MAXCHECKS max)..." + if docker compose -f "${DIR}/docker-compose.3-node-3-dc.yaml" exec -T "${SERVICE}" nodetool status >/dev/null; then + READY=1 + break + fi + sleep "${CHECKINTERVAL}" + done + + if [ -z ${READY} ]; then + fail-now "timed out waiting for ${SERVICE} to be ready" + fi +} + +test-cassandra() { + log-info "starting Cassandra cluster with 3 datacenters and 3 nodes..." + cassandra-up cassandra-1 + log-info "Cassandra node 1 is up, bringing up node 2..." + cassandra-up cassandra-2 + log-info "Cassandra node 2 is up, bringing up node 3..." + cassandra-up cassandra-3 + log-info "Cassandra node 3 is up, cluster is ready for testing" + + log-info "running tests for Cassandra datastore plugin with 3 datacenters and 3 nodes..." + ./cassandra.test -test.parallel 1 -test.v || fail-now "tests failed" + # A single test can be focused here if desired, like this: + # ./cassandra.test -test.v -testify.m "^(TestListRegistrationEntries)$" || fail-now "tests failed" +} + +test-cassandra cassandra-5 || exit 1 diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/node-1.yml b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/node-1.yml new file mode 100755 index 0000000000..37ea6c387d --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/node-1.yml @@ -0,0 +1,13 @@ +native_transport_port: 9044 +cluster_name: spire_test +endpoint_snitch: GossipingPropertyFileSnitch +listen_address: 172.21.0.3 +rpc_address: 0.0.0.0 +broadcast_rpc_address: 127.0.0.1 +commitlog_sync: periodic +commitlog_sync_period: 10s +partitioner: org.apache.cassandra.dht.Murmur3Partitioner +seed_provider: + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + - seeds: "172.21.0.3:7000" \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/node-2.yml b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/node-2.yml new file mode 100755 index 0000000000..a36802619d --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/node-2.yml @@ -0,0 +1,13 @@ +native_transport_port: 9045 +cluster_name: spire_test +endpoint_snitch: GossipingPropertyFileSnitch +listen_address: 172.21.0.4 +rpc_address: 0.0.0.0 +broadcast_rpc_address: 127.0.0.1 +commitlog_sync: periodic +commitlog_sync_period: 10s +partitioner: org.apache.cassandra.dht.Murmur3Partitioner +seed_provider: + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + - seeds: "172.21.0.3:7000" \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/node-3.yml b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/node-3.yml new file mode 100755 index 0000000000..ca0b75b866 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/node-3.yml @@ -0,0 +1,13 @@ +native_transport_port: 9046 +cluster_name: spire_test +endpoint_snitch: GossipingPropertyFileSnitch +listen_address: 172.21.0.5 +rpc_address: 0.0.0.0 +broadcast_rpc_address: 127.0.0.1 +commitlog_sync: periodic +commitlog_sync_period: 10s +partitioner: org.apache.cassandra.dht.Murmur3Partitioner +seed_provider: + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + - seeds: "172.21.0.3:7000" \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/rackdc.node1.properties b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/rackdc.node1.properties new file mode 100644 index 0000000000..29474553e2 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/rackdc.node1.properties @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# These properties are used with GossipingPropertyFileSnitch and will +# indicate the rack and dc for this node +dc=dc1 +rack=rack1 + +# Add a suffix to a datacenter name. Used by all cloud-based snitches. +#dc_suffix= + +# Uncomment the following line to make this snitch prefer the internal ip when possible, as the Ec2MultiRegionSnitch does. +# prefer_local=true + +# Datacenter and rack naming convention used by the Ec2Snitch and Ec2MultiRegionSnitch. +# Options are: +# legacy : datacenter name is the part of the availability zone name preceding the last "-" +# when the zone ends in -1 and includes the number if not -1. Rack is the portion of +# the availability zone name following the last "-". +# Examples: us-west-1a => dc: us-west, rack: 1a; us-west-2b => dc: us-west-2, rack: 2b; +# YOU MUST USE THIS VALUE IF YOU ARE UPGRADING A PRE-4.0 CLUSTER +# standard : Default value. datacenter name is the standard AWS region name, including the number. +# rack name is the region plus the availability zone letter. +# Examples: us-west-1a => dc: us-west-1, rack: us-west-1a; us-west-2b => dc: us-west-2, rack: us-west-2b; +# ec2_naming_scheme=standard +# +# Type of AWS IMDS, value might be either "v1" or "v2". Defaults to "v2". +# ec2_metadata_type=v2 +# If AWS IMDS of v2 is configured, ec2_metadata_token_ttl_seconds says how many seconds a token will be valid until +# it is refreshed. Defaults to 21600. Can not be smaller than 30 and bigger than 21600. Has to be an integer. +# ec2_metadata_token_ttl_seconds=21600 + +# AzureSnitch +# Options are: +# Version of API to talk to. When not set, defaults to '2021-12-13'. +# azure_api_version=2021-12-13 + +# For all cloud-based snitches, there are following options available: +# +# Property to change metadata service url for a cloud-based snitch. The endpoint of a particular +# snitch will be appended to this property. A user is not normally using this property, it is here +# for tweaking the url of a service itself, e.g. for testing purposes. +# metadata_url=http://some/service +# +# Sets a specified timeout value, in duration format, to be used when opening a communications link to metadata service, +# referenced by an URLConnection. The timeout of zero (0s) is interpreted as an infinite timeout. +# Defaults to 30 seconds. +# metadata_request_timeout=30s \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/rackdc.node2.properties b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/rackdc.node2.properties new file mode 100644 index 0000000000..c7eff35b65 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/rackdc.node2.properties @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# These properties are used with GossipingPropertyFileSnitch and will +# indicate the rack and dc for this node +dc=dc2 +rack=rack2 + +# Add a suffix to a datacenter name. Used by all cloud-based snitches. +#dc_suffix= + +# Uncomment the following line to make this snitch prefer the internal ip when possible, as the Ec2MultiRegionSnitch does. +# prefer_local=true + +# Datacenter and rack naming convention used by the Ec2Snitch and Ec2MultiRegionSnitch. +# Options are: +# legacy : datacenter name is the part of the availability zone name preceding the last "-" +# when the zone ends in -1 and includes the number if not -1. Rack is the portion of +# the availability zone name following the last "-". +# Examples: us-west-1a => dc: us-west, rack: 1a; us-west-2b => dc: us-west-2, rack: 2b; +# YOU MUST USE THIS VALUE IF YOU ARE UPGRADING A PRE-4.0 CLUSTER +# standard : Default value. datacenter name is the standard AWS region name, including the number. +# rack name is the region plus the availability zone letter. +# Examples: us-west-1a => dc: us-west-1, rack: us-west-1a; us-west-2b => dc: us-west-2, rack: us-west-2b; +# ec2_naming_scheme=standard +# +# Type of AWS IMDS, value might be either "v1" or "v2". Defaults to "v2". +# ec2_metadata_type=v2 +# If AWS IMDS of v2 is configured, ec2_metadata_token_ttl_seconds says how many seconds a token will be valid until +# it is refreshed. Defaults to 21600. Can not be smaller than 30 and bigger than 21600. Has to be an integer. +# ec2_metadata_token_ttl_seconds=21600 + +# AzureSnitch +# Options are: +# Version of API to talk to. When not set, defaults to '2021-12-13'. +# azure_api_version=2021-12-13 + +# For all cloud-based snitches, there are following options available: +# +# Property to change metadata service url for a cloud-based snitch. The endpoint of a particular +# snitch will be appended to this property. A user is not normally using this property, it is here +# for tweaking the url of a service itself, e.g. for testing purposes. +# metadata_url=http://some/service +# +# Sets a specified timeout value, in duration format, to be used when opening a communications link to metadata service, +# referenced by an URLConnection. The timeout of zero (0s) is interpreted as an infinite timeout. +# Defaults to 30 seconds. +# metadata_request_timeout=30s \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/rackdc.node3.properties b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/rackdc.node3.properties new file mode 100644 index 0000000000..81afbf14d6 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/3-node-3-dc/rackdc.node3.properties @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# These properties are used with GossipingPropertyFileSnitch and will +# indicate the rack and dc for this node +dc=dc3 +rack=rack3 + +# Add a suffix to a datacenter name. Used by all cloud-based snitches. +#dc_suffix= + +# Uncomment the following line to make this snitch prefer the internal ip when possible, as the Ec2MultiRegionSnitch does. +# prefer_local=true + +# Datacenter and rack naming convention used by the Ec2Snitch and Ec2MultiRegionSnitch. +# Options are: +# legacy : datacenter name is the part of the availability zone name preceding the last "-" +# when the zone ends in -1 and includes the number if not -1. Rack is the portion of +# the availability zone name following the last "-". +# Examples: us-west-1a => dc: us-west, rack: 1a; us-west-2b => dc: us-west-2, rack: 2b; +# YOU MUST USE THIS VALUE IF YOU ARE UPGRADING A PRE-4.0 CLUSTER +# standard : Default value. datacenter name is the standard AWS region name, including the number. +# rack name is the region plus the availability zone letter. +# Examples: us-west-1a => dc: us-west-1, rack: us-west-1a; us-west-2b => dc: us-west-2, rack: us-west-2b; +# ec2_naming_scheme=standard +# +# Type of AWS IMDS, value might be either "v1" or "v2". Defaults to "v2". +# ec2_metadata_type=v2 +# If AWS IMDS of v2 is configured, ec2_metadata_token_ttl_seconds says how many seconds a token will be valid until +# it is refreshed. Defaults to 21600. Can not be smaller than 30 and bigger than 21600. Has to be an integer. +# ec2_metadata_token_ttl_seconds=21600 + +# AzureSnitch +# Options are: +# Version of API to talk to. When not set, defaults to '2021-12-13'. +# azure_api_version=2021-12-13 + +# For all cloud-based snitches, there are following options available: +# +# Property to change metadata service url for a cloud-based snitch. The endpoint of a particular +# snitch will be appended to this property. A user is not normally using this property, it is here +# for tweaking the url of a service itself, e.g. for testing purposes. +# metadata_url=http://some/service +# +# Sets a specified timeout value, in duration format, to be used when opening a communications link to metadata service, +# referenced by an URLConnection. The timeout of zero (0s) is interpreted as an infinite timeout. +# Defaults to 30 seconds. +# metadata_request_timeout=30s \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/README.md b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/README.md new file mode 100644 index 0000000000..2a48e65694 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/README.md @@ -0,0 +1,4 @@ +# Multi-node Datastore Cassandra Tests + +This suite tests the datastore on a Cassandra cluster with three nodes in three different datacenters. It is not perfectly +stable and is known to flake at times, likely due to incomplete truncation of the tables in the keyspace. \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/docker-compose.3-node-3-dc.yaml b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/docker-compose.3-node-3-dc.yaml new file mode 100644 index 0000000000..88a47bbcad --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/docker-compose.3-node-3-dc.yaml @@ -0,0 +1,99 @@ +networks: + spire-cassandra-network: + ipam: + config: + - subnet: 172.21.0.0/24 + +services: + cassandra-1: + container_name: cassandra-1 + image: cassandra:5 + command: + - "-Dcassandra.config=/cassandra.yaml" + ports: + - "9044:9044" + env_file: + - ../.env + environment: + - HEAP_NEWSIZE=1024M + - MAX_HEAP_SIZE=4096M + volumes: + - cassandra-node-1:/var/lib/cassandra:rw + - ./3-node-3-dc/node-1.yml:/cassandra.yaml:rw + - ./3-node-3-dc/rackdc.node1.properties:/etc/cassandra/cassandra-rackdc.properties:rw + restart: + on-failure + healthcheck: + test: ["CMD-SHELL", "nodetool status"] + interval: 2m + start_period: 2m + timeout: 10s + retries: 3 + networks: + spire-cassandra-network: + ipv4_address: 172.21.0.3 + cassandra-2: + container_name: cassandra-2 + image: cassandra:5 + command: + - "-Dcassandra.config=/cassandra.yaml" + ports: + - "9045:9045" + env_file: + - ../.env + environment: + - HEAP_NEWSIZE=1024M + - MAX_HEAP_SIZE=4096M + volumes: + - cassandra-node-2:/var/lib/cassandra:rw + - ./3-node-3-dc/node-2.yml:/cassandra.yaml:rw + - ./3-node-3-dc/rackdc.node2.properties:/etc/cassandra/cassandra-rackdc.properties:rw + restart: + on-failure + healthcheck: + test: ["CMD-SHELL", "nodetool status"] + interval: 2m + start_period: 2m + timeout: 10s + retries: 3 + depends_on: + cassandra-1: + condition: service_healthy + networks: + spire-cassandra-network: + ipv4_address: 172.21.0.4 + cassandra-3: + container_name: cassandra-3 + image: cassandra:5 + command: + - "-Dcassandra.config=/cassandra.yaml" + ports: + - "9046:9046" + env_file: + - ../.env + environment: + - HEAP_NEWSIZE=1024M + - MAX_HEAP_SIZE=4096M + volumes: + - cassandra-node-3:/var/lib/cassandra:rw + - ./3-node-3-dc/node-3.yml:/cassandra.yaml:rw + - ./3-node-3-dc/rackdc.node3.properties:/etc/cassandra/cassandra-rackdc.properties:rw + restart: + on-failure + healthcheck: + test: ["CMD-SHELL", "nodetool status"] + interval: 2m + start_period: 2m + timeout: 10s + retries: 3 + depends_on: + cassandra-2: + condition: service_healthy + networks: + spire-cassandra-network: + ipv4_address: 172.21.0.5 + +volumes: + cassandra-node-1: + cassandra-node-2: + cassandra-node-3: \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/teardown b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/teardown new file mode 100755 index 0000000000..b6ba6eb276 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode-multidc/teardown @@ -0,0 +1,14 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +docker-down() { + if [ $# -eq 0 ]; then + log-debug "bringing down services..." + else + log-debug "bringing down $*..." + fi + docker compose -f "${DIR}/docker-compose.3-node-3-dc.yaml" down -v || fail-now "failed to bring down services." +} + +docker-down \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode/.env b/test/integration/cassandra-suites/datastore-cassandra-multinode/.env new file mode 100644 index 0000000000..96d54b56ed --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode/.env @@ -0,0 +1,4 @@ +HTTP_PROXY= +HTTPS_PROXY= +http_proxy= +https_proxy= \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode/00-setup b/test/integration/cassandra-suites/datastore-cassandra-multinode/00-setup new file mode 100755 index 0000000000..feb3e56526 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode/00-setup @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +PKGDIR="${REPODIR}/pkg/server/datastore" + +log-debug "building cassandra test harness..." +(cd "${PKGDIR}"; go test -c -o "${DIR}"/cassandra.test -ldflags '-X github.com/spiffe/spire/pkg/server/datastore_test.TestDialect=cassandra -X github.com/spiffe/spire/pkg/server/datastore_test.TestConnString=["localhost:9044","localhost:9045","localhost:9046"];spire_test') + +log-debug "copying over test data..." +cp -r "${PKGDIR}"/testdata . diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode/01-test-variants b/test/integration/cassandra-suites/datastore-cassandra-multinode/01-test-variants new file mode 100755 index 0000000000..85793b5cac --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode/01-test-variants @@ -0,0 +1,53 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +docker-compose-up() { + if [ $# -eq 0 ]; then + log-debug "bringing up services..." + else + log-debug "bringing up $*..." + fi + docker compose -f "${DIR}/docker-compose.3-node-1-dc.yaml" up -d "$@" || fail-now "failed to bring up services." +} + +cassandra-up() { + SERVICE=$1 + + docker-compose-up "${SERVICE}" + + # Wait up to two minutes for each Cassandra node to be available. It should come up + # pretty quick on developer machines but CI/CD is slow. + MAXCHECKS=40 + CHECKINTERVAL=3 + READY= + for ((i=1;i<=MAXCHECKS;i++)); do + log-info "waiting for ${SERVICE} ($i of $MAXCHECKS max)..." + if docker compose -f "${DIR}/docker-compose.3-node-1-dc.yaml" exec -T "${SERVICE}" nodetool status >/dev/null; then + READY=1 + break + fi + sleep "${CHECKINTERVAL}" + done + + if [ -z ${READY} ]; then + fail-now "timed out waiting for ${SERVICE} to be ready" + fi +} + +test-cassandra() { + log-info "starting Cassandra cluster with one datacenter and 3 nodes..." + cassandra-up cassandra-1 + log-info "Cassandra node 1 is up, bringing up node 2..." + cassandra-up cassandra-2 + log-info "Cassandra node 2 is up, bringing up node 3..." + cassandra-up cassandra-3 + log-info "Cassandra node 3 is up, cluster is ready for testing" + + log-info "running tests for Cassandra datastore plugin with one datacenter and 3 nodes..." + ./cassandra.test -test.parallel 1 -test.v || fail-now "tests failed" + # A single test can be focused here if desired, like this: + # ./cassandra.test -test.v -testify.m "^(TestListRegistrationEntries)$" || fail-now "tests failed" +} + +test-cassandra cassandra-5 || exit 1 diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/node-1.yml b/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/node-1.yml new file mode 100755 index 0000000000..37ea6c387d --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/node-1.yml @@ -0,0 +1,13 @@ +native_transport_port: 9044 +cluster_name: spire_test +endpoint_snitch: GossipingPropertyFileSnitch +listen_address: 172.21.0.3 +rpc_address: 0.0.0.0 +broadcast_rpc_address: 127.0.0.1 +commitlog_sync: periodic +commitlog_sync_period: 10s +partitioner: org.apache.cassandra.dht.Murmur3Partitioner +seed_provider: + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + - seeds: "172.21.0.3:7000" \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/node-2.yml b/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/node-2.yml new file mode 100755 index 0000000000..a36802619d --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/node-2.yml @@ -0,0 +1,13 @@ +native_transport_port: 9045 +cluster_name: spire_test +endpoint_snitch: GossipingPropertyFileSnitch +listen_address: 172.21.0.4 +rpc_address: 0.0.0.0 +broadcast_rpc_address: 127.0.0.1 +commitlog_sync: periodic +commitlog_sync_period: 10s +partitioner: org.apache.cassandra.dht.Murmur3Partitioner +seed_provider: + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + - seeds: "172.21.0.3:7000" \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/node-3.yml b/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/node-3.yml new file mode 100755 index 0000000000..ca0b75b866 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/node-3.yml @@ -0,0 +1,13 @@ +native_transport_port: 9046 +cluster_name: spire_test +endpoint_snitch: GossipingPropertyFileSnitch +listen_address: 172.21.0.5 +rpc_address: 0.0.0.0 +broadcast_rpc_address: 127.0.0.1 +commitlog_sync: periodic +commitlog_sync_period: 10s +partitioner: org.apache.cassandra.dht.Murmur3Partitioner +seed_provider: + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + - seeds: "172.21.0.3:7000" \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/rackdc.node1.properties b/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/rackdc.node1.properties new file mode 100644 index 0000000000..29474553e2 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/rackdc.node1.properties @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# These properties are used with GossipingPropertyFileSnitch and will +# indicate the rack and dc for this node +dc=dc1 +rack=rack1 + +# Add a suffix to a datacenter name. Used by all cloud-based snitches. +#dc_suffix= + +# Uncomment the following line to make this snitch prefer the internal ip when possible, as the Ec2MultiRegionSnitch does. +# prefer_local=true + +# Datacenter and rack naming convention used by the Ec2Snitch and Ec2MultiRegionSnitch. +# Options are: +# legacy : datacenter name is the part of the availability zone name preceding the last "-" +# when the zone ends in -1 and includes the number if not -1. Rack is the portion of +# the availability zone name following the last "-". +# Examples: us-west-1a => dc: us-west, rack: 1a; us-west-2b => dc: us-west-2, rack: 2b; +# YOU MUST USE THIS VALUE IF YOU ARE UPGRADING A PRE-4.0 CLUSTER +# standard : Default value. datacenter name is the standard AWS region name, including the number. +# rack name is the region plus the availability zone letter. +# Examples: us-west-1a => dc: us-west-1, rack: us-west-1a; us-west-2b => dc: us-west-2, rack: us-west-2b; +# ec2_naming_scheme=standard +# +# Type of AWS IMDS, value might be either "v1" or "v2". Defaults to "v2". +# ec2_metadata_type=v2 +# If AWS IMDS of v2 is configured, ec2_metadata_token_ttl_seconds says how many seconds a token will be valid until +# it is refreshed. Defaults to 21600. Can not be smaller than 30 and bigger than 21600. Has to be an integer. +# ec2_metadata_token_ttl_seconds=21600 + +# AzureSnitch +# Options are: +# Version of API to talk to. When not set, defaults to '2021-12-13'. +# azure_api_version=2021-12-13 + +# For all cloud-based snitches, there are following options available: +# +# Property to change metadata service url for a cloud-based snitch. The endpoint of a particular +# snitch will be appended to this property. A user is not normally using this property, it is here +# for tweaking the url of a service itself, e.g. for testing purposes. +# metadata_url=http://some/service +# +# Sets a specified timeout value, in duration format, to be used when opening a communications link to metadata service, +# referenced by an URLConnection. The timeout of zero (0s) is interpreted as an infinite timeout. +# Defaults to 30 seconds. +# metadata_request_timeout=30s \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/rackdc.node2.properties b/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/rackdc.node2.properties new file mode 100644 index 0000000000..0e705caa22 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/rackdc.node2.properties @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# These properties are used with GossipingPropertyFileSnitch and will +# indicate the rack and dc for this node +dc=dc1 +rack=rack2 + +# Add a suffix to a datacenter name. Used by all cloud-based snitches. +#dc_suffix= + +# Uncomment the following line to make this snitch prefer the internal ip when possible, as the Ec2MultiRegionSnitch does. +# prefer_local=true + +# Datacenter and rack naming convention used by the Ec2Snitch and Ec2MultiRegionSnitch. +# Options are: +# legacy : datacenter name is the part of the availability zone name preceding the last "-" +# when the zone ends in -1 and includes the number if not -1. Rack is the portion of +# the availability zone name following the last "-". +# Examples: us-west-1a => dc: us-west, rack: 1a; us-west-2b => dc: us-west-2, rack: 2b; +# YOU MUST USE THIS VALUE IF YOU ARE UPGRADING A PRE-4.0 CLUSTER +# standard : Default value. datacenter name is the standard AWS region name, including the number. +# rack name is the region plus the availability zone letter. +# Examples: us-west-1a => dc: us-west-1, rack: us-west-1a; us-west-2b => dc: us-west-2, rack: us-west-2b; +# ec2_naming_scheme=standard +# +# Type of AWS IMDS, value might be either "v1" or "v2". Defaults to "v2". +# ec2_metadata_type=v2 +# If AWS IMDS of v2 is configured, ec2_metadata_token_ttl_seconds says how many seconds a token will be valid until +# it is refreshed. Defaults to 21600. Can not be smaller than 30 and bigger than 21600. Has to be an integer. +# ec2_metadata_token_ttl_seconds=21600 + +# AzureSnitch +# Options are: +# Version of API to talk to. When not set, defaults to '2021-12-13'. +# azure_api_version=2021-12-13 + +# For all cloud-based snitches, there are following options available: +# +# Property to change metadata service url for a cloud-based snitch. The endpoint of a particular +# snitch will be appended to this property. A user is not normally using this property, it is here +# for tweaking the url of a service itself, e.g. for testing purposes. +# metadata_url=http://some/service +# +# Sets a specified timeout value, in duration format, to be used when opening a communications link to metadata service, +# referenced by an URLConnection. The timeout of zero (0s) is interpreted as an infinite timeout. +# Defaults to 30 seconds. +# metadata_request_timeout=30s \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/rackdc.node3.properties b/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/rackdc.node3.properties new file mode 100644 index 0000000000..8e51b76da9 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode/3-node-1-dc/rackdc.node3.properties @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# These properties are used with GossipingPropertyFileSnitch and will +# indicate the rack and dc for this node +dc=dc1 +rack=rack3 + +# Add a suffix to a datacenter name. Used by all cloud-based snitches. +#dc_suffix= + +# Uncomment the following line to make this snitch prefer the internal ip when possible, as the Ec2MultiRegionSnitch does. +# prefer_local=true + +# Datacenter and rack naming convention used by the Ec2Snitch and Ec2MultiRegionSnitch. +# Options are: +# legacy : datacenter name is the part of the availability zone name preceding the last "-" +# when the zone ends in -1 and includes the number if not -1. Rack is the portion of +# the availability zone name following the last "-". +# Examples: us-west-1a => dc: us-west, rack: 1a; us-west-2b => dc: us-west-2, rack: 2b; +# YOU MUST USE THIS VALUE IF YOU ARE UPGRADING A PRE-4.0 CLUSTER +# standard : Default value. datacenter name is the standard AWS region name, including the number. +# rack name is the region plus the availability zone letter. +# Examples: us-west-1a => dc: us-west-1, rack: us-west-1a; us-west-2b => dc: us-west-2, rack: us-west-2b; +# ec2_naming_scheme=standard +# +# Type of AWS IMDS, value might be either "v1" or "v2". Defaults to "v2". +# ec2_metadata_type=v2 +# If AWS IMDS of v2 is configured, ec2_metadata_token_ttl_seconds says how many seconds a token will be valid until +# it is refreshed. Defaults to 21600. Can not be smaller than 30 and bigger than 21600. Has to be an integer. +# ec2_metadata_token_ttl_seconds=21600 + +# AzureSnitch +# Options are: +# Version of API to talk to. When not set, defaults to '2021-12-13'. +# azure_api_version=2021-12-13 + +# For all cloud-based snitches, there are following options available: +# +# Property to change metadata service url for a cloud-based snitch. The endpoint of a particular +# snitch will be appended to this property. A user is not normally using this property, it is here +# for tweaking the url of a service itself, e.g. for testing purposes. +# metadata_url=http://some/service +# +# Sets a specified timeout value, in duration format, to be used when opening a communications link to metadata service, +# referenced by an URLConnection. The timeout of zero (0s) is interpreted as an infinite timeout. +# Defaults to 30 seconds. +# metadata_request_timeout=30s \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode/README.md b/test/integration/cassandra-suites/datastore-cassandra-multinode/README.md new file mode 100644 index 0000000000..31a60162b5 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode/README.md @@ -0,0 +1,4 @@ +# Multi-node Datastore Cassandra Tests + +This suite tests the datastore on a Cassandra cluster with three nodes in a single datacenter. It is not perfectly +stable and is known to flake at times, likely due to incomplete truncation of the tables in the keyspace. \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode/docker-compose.3-node-1-dc.yaml b/test/integration/cassandra-suites/datastore-cassandra-multinode/docker-compose.3-node-1-dc.yaml new file mode 100644 index 0000000000..aa050a296b --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode/docker-compose.3-node-1-dc.yaml @@ -0,0 +1,99 @@ +networks: + spire-cassandra-network: + ipam: + config: + - subnet: 172.21.0.0/24 + +services: + cassandra-1: + container_name: cassandra-1 + image: cassandra:5 + command: + - "-Dcassandra.config=/cassandra.yaml" + ports: + - "9044:9044" + env_file: + - ./.env + environment: + - HEAP_NEWSIZE=1024M + - MAX_HEAP_SIZE=4096M + volumes: + - cassandra-node-1:/var/lib/cassandra:rw + - ./3-node-1-dc/node-1.yml:/cassandra.yaml:rw + - ./3-node-1-dc/rackdc.node1.properties:/etc/cassandra/cassandra-rackdc.properties:rw + restart: + on-failure + healthcheck: + test: ["CMD-SHELL", "nodetool status"] + interval: 2m + start_period: 2m + timeout: 10s + retries: 3 + networks: + spire-cassandra-network: + ipv4_address: 172.21.0.3 + cassandra-2: + container_name: cassandra-2 + image: cassandra:5 + command: + - "-Dcassandra.config=/cassandra.yaml" + ports: + - "9045:9045" + env_file: + - ./.env + environment: + - HEAP_NEWSIZE=1024M + - MAX_HEAP_SIZE=4096M + volumes: + - cassandra-node-2:/var/lib/cassandra:rw + - ./3-node-1-dc/node-2.yml:/cassandra.yaml:rw + - ./3-node-1-dc/rackdc.node2.properties:/etc/cassandra/cassandra-rackdc.properties:rw + restart: + on-failure + healthcheck: + test: ["CMD-SHELL", "nodetool status"] + interval: 2m + start_period: 2m + timeout: 10s + retries: 3 + depends_on: + cassandra-1: + condition: service_healthy + networks: + spire-cassandra-network: + ipv4_address: 172.21.0.4 + cassandra-3: + container_name: cassandra-3 + image: cassandra:5 + command: + - "-Dcassandra.config=/cassandra.yaml" + ports: + - "9046:9046" + env_file: + - ./.env + environment: + - HEAP_NEWSIZE=1024M + - MAX_HEAP_SIZE=4096M + volumes: + - cassandra-node-3:/var/lib/cassandra:rw + - ./3-node-1-dc/node-3.yml:/cassandra.yaml:rw + - ./3-node-1-dc/rackdc.node3.properties:/etc/cassandra/cassandra-rackdc.properties:rw + restart: + on-failure + healthcheck: + test: ["CMD-SHELL", "nodetool status"] + interval: 2m + start_period: 2m + timeout: 10s + retries: 3 + depends_on: + cassandra-2: + condition: service_healthy + networks: + spire-cassandra-network: + ipv4_address: 172.21.0.5 + +volumes: + cassandra-node-1: + cassandra-node-2: + cassandra-node-3: \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra-multinode/teardown b/test/integration/cassandra-suites/datastore-cassandra-multinode/teardown new file mode 100755 index 0000000000..b853ac5898 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra-multinode/teardown @@ -0,0 +1,14 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +docker-down() { + if [ $# -eq 0 ]; then + log-debug "bringing down services..." + else + log-debug "bringing down $*..." + fi + docker compose -f "${DIR}/docker-compose.3-node-1-dc.yaml" down -v || fail-now "failed to bring down services." +} + +docker-down \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra/00-setup b/test/integration/cassandra-suites/datastore-cassandra/00-setup new file mode 100755 index 0000000000..a60cc7d272 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra/00-setup @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +PKGDIR="${REPODIR}/pkg/server/datastore" + +log-debug "building cassandra test harness..." +(cd "${PKGDIR}"; go test -c -o "${DIR}"/cassandra.test -ldflags '-X github.com/spiffe/spire/pkg/server/datastore_test.TestDialect=cassandra -X github.com/spiffe/spire/pkg/server/datastore_test.TestConnString=["localhost:9053"];spire_test') + +log-debug "copying over test data..." +cp -r "${PKGDIR}"/testdata . diff --git a/test/integration/cassandra-suites/datastore-cassandra/01-test-variants b/test/integration/cassandra-suites/datastore-cassandra/01-test-variants new file mode 100755 index 0000000000..b78f253ed1 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra/01-test-variants @@ -0,0 +1,31 @@ +#!/bin/bash + +test-cassandra() { + SERVICE=$1 + + docker-up "${SERVICE}" + + # Wait up to two minutes for postgres to be available. It should come up + # pretty quick on developer machines but CI/CD is slow. + MAXCHECKS=40 + CHECKINTERVAL=3 + READY= + for ((i=1;i<=MAXCHECKS;i++)); do + log-info "waiting for ${SERVICE} ($i of $MAXCHECKS max)..." + if docker compose exec -T "${SERVICE}" nodetool status >/dev/null; then + READY=1 + break + fi + sleep "${CHECKINTERVAL}" + done + + if [ -z ${READY} ]; then + fail-now "timed out waiting for ${SERVICE} to be ready" + fi + + log-info "running tests against ${SERVICE}..." + ./cassandra.test || fail-now "tests failed" + docker-stop "${SERVICE}" +} + +test-cassandra cassandra-5 || exit 1 diff --git a/test/integration/cassandra-suites/datastore-cassandra/README.md b/test/integration/cassandra-suites/datastore-cassandra/README.md new file mode 100644 index 0000000000..f87f5c14cb --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra/README.md @@ -0,0 +1 @@ +# TODO \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra/docker-compose.single-node.yaml b/test/integration/cassandra-suites/datastore-cassandra/docker-compose.single-node.yaml new file mode 100644 index 0000000000..3009ec7723 --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra/docker-compose.single-node.yaml @@ -0,0 +1,39 @@ +networks: + spire-cassandra-network-single: + ipam: + config: + - subnet: 172.21.1.0/24 + +services: + cassandra-node-single: + container_name: cassandra-node-single + image: cassandra:5 + command: + - "-Dcassandra.config=/cassandra.yaml" + ports: + - "9044:9044" + env_file: + - ../.env + environment: + - HEAP_NEWSIZE=512M + - MAX_HEAP_SIZE=1024M + volumes: + - cassandra-node-single:/var/lib/cassandra:rw + - ./agent-pki/cassandra.cert.pem:/cassandra.crt:ro + - ./agent-pki/cassandra.key.pem:/cassandra.key:ro + - ./cassandra/1-node-1-dc/node-1.yml:/cassandra.yaml:rw + - ./cassandra/1-node-1-dc/rackdc.node1.properties:/etc/cassandra/cassandra-rackdc.properties:rw + restart: + on-failure + healthcheck: + test: ["CMD-SHELL", "nodetool status"] + interval: 2m + start_period: 2m + timeout: 10s + retries: 3 + networks: + spire-cassandra-network-single: + ipv4_address: 172.21.1.3 + +volumes: + cassandra-node-single: \ No newline at end of file diff --git a/test/integration/cassandra-suites/datastore-cassandra/teardown b/test/integration/cassandra-suites/datastore-cassandra/teardown new file mode 100755 index 0000000000..89646a7e9b --- /dev/null +++ b/test/integration/cassandra-suites/datastore-cassandra/teardown @@ -0,0 +1,4 @@ +#!/bin/bash + +docker-down +docker-cleanup \ No newline at end of file diff --git a/test/integration/cassandra-suites/debug-endpoints/00-setup b/test/integration/cassandra-suites/debug-endpoints/00-setup new file mode 100755 index 0000000000..ce87d726bd --- /dev/null +++ b/test/integration/cassandra-suites/debug-endpoints/00-setup @@ -0,0 +1,7 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent + +"${ROOTDIR}/setup/debugserver/build.sh" "${RUNDIR}/conf/server/debugclient" +"${ROOTDIR}/setup/debugagent/build.sh" "${RUNDIR}/conf/agent/debugclient" + diff --git a/test/integration/cassandra-suites/debug-endpoints/01-start-server b/test/integration/cassandra-suites/debug-endpoints/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/debug-endpoints/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/debug-endpoints/02-bootstrap-agent b/test/integration/cassandra-suites/debug-endpoints/02-bootstrap-agent new file mode 100755 index 0000000000..8ee7d32c26 --- /dev/null +++ b/test/integration/cassandra-suites/debug-endpoints/02-bootstrap-agent @@ -0,0 +1,5 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt diff --git a/test/integration/cassandra-suites/debug-endpoints/03-start-agent b/test/integration/cassandra-suites/debug-endpoints/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/debug-endpoints/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/debug-endpoints/04-create-registration-entries b/test/integration/cassandra-suites/debug-endpoints/04-create-registration-entries new file mode 100755 index 0000000000..33c41a9b15 --- /dev/null +++ b/test/integration/cassandra-suites/debug-endpoints/04-create-registration-entries @@ -0,0 +1,21 @@ +#!/bin/bash + +log-debug "creating admin registration entry..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/admin" \ + -selector "unix:uid:1001" \ + -admin \ + -x509SVIDTTL 0 +check-synced-entry "spire-agent" "spiffe://domain.test/admin" + +log-debug "creating regular registration entry..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload" \ + -selector "unix:uid:1002" \ + -x509SVIDTTL 0 +check-synced-entry "spire-agent" "spiffe://domain.test/workload" + diff --git a/test/integration/cassandra-suites/debug-endpoints/05-test-endpoints b/test/integration/cassandra-suites/debug-endpoints/05-test-endpoints new file mode 100755 index 0000000000..c1bd8bf382 --- /dev/null +++ b/test/integration/cassandra-suites/debug-endpoints/05-test-endpoints @@ -0,0 +1,25 @@ +#!/bin/bash + +MAXCHECKS=10 +CHECKINTERVAL=1 +# Call debug endpoints every 1s for 30s +for ((i=1; i<=MAXCHECKS;i++)); do + log-info "test server debug endpoints ($i of $MAXCHECKS max)..." + docker compose exec -T spire-server \ + /opt/spire/conf/server/debugclient || fail-now "failed to check server debug endpoints" + + log-info "test agent debug endpoints ($i of $MAXCHECKS max)..." + docker compose exec -T spire-agent \ + /opt/spire/conf/agent/debugclient || fail-now "failed to check agent debug endpoints" + sleep $CHECKINTERVAL +done + +# Verify server TCP server does not implements Debug endpoint +docker compose exec -u 1001 -T spire-agent \ + /opt/spire/conf/agent/debugclient -testCase "serverWithWorkload" || fail-now "failed to check server debug endpoints using admin workload" + +docker compose exec -u 1002 -T spire-agent \ + /opt/spire/conf/agent/debugclient -testCase "serverWithWorkload" || fail-now "failed to check server debug endpoints using regular workload" + +docker compose exec -T spire-agent \ + /opt/spire/conf/agent/debugclient -testCase "serverWithInsecure" || fail-now "failed to check server debug endpoints using insecure connection" diff --git a/test/integration/cassandra-suites/debug-endpoints/README.md b/test/integration/cassandra-suites/debug-endpoints/README.md new file mode 100644 index 0000000000..998ad8233c --- /dev/null +++ b/test/integration/cassandra-suites/debug-endpoints/README.md @@ -0,0 +1,5 @@ +# Debug Endpoints Suite + +## Description + +This suite validates debug endpoints diff --git a/test/integration/cassandra-suites/debug-endpoints/conf/agent/agent.conf b/test/integration/cassandra-suites/debug-endpoints/conf/agent/agent.conf new file mode 100644 index 0000000000..19b71088d2 --- /dev/null +++ b/test/integration/cassandra-suites/debug-endpoints/conf/agent/agent.conf @@ -0,0 +1,28 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" + + admin_socket_path = "/opt/debug.sock" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/debug-endpoints/conf/server/server.conf b/test/integration/cassandra-suites/debug-endpoints/conf/server/server.conf new file mode 100644 index 0000000000..2b324efd99 --- /dev/null +++ b/test/integration/cassandra-suites/debug-endpoints/conf/server/server.conf @@ -0,0 +1,48 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/debug-endpoints/docker-compose.yaml b/test/integration/cassandra-suites/debug-endpoints/docker-compose.yaml new file mode 100644 index 0000000000..d8c2241661 --- /dev/null +++ b/test/integration/cassandra-suites/debug-endpoints/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + spire-server: + image: spire-server:latest-local + env_file: + - ../.env + hostname: spire-server + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/debug-endpoints/teardown b/test/integration/cassandra-suites/debug-endpoints/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/debug-endpoints/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/delegatedidentity/00-setup b/test/integration/cassandra-suites/delegatedidentity/00-setup new file mode 100755 index 0000000000..5e6cf63958 --- /dev/null +++ b/test/integration/cassandra-suites/delegatedidentity/00-setup @@ -0,0 +1,5 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent + +"${ROOTDIR}/setup/delegatedidentity/build.sh" "${RUNDIR}/conf/agent/delegatedidentityclient" diff --git a/test/integration/cassandra-suites/delegatedidentity/01-start-server b/test/integration/cassandra-suites/delegatedidentity/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/delegatedidentity/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/delegatedidentity/02-bootstrap-agent b/test/integration/cassandra-suites/delegatedidentity/02-bootstrap-agent new file mode 100755 index 0000000000..8ee7d32c26 --- /dev/null +++ b/test/integration/cassandra-suites/delegatedidentity/02-bootstrap-agent @@ -0,0 +1,5 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt diff --git a/test/integration/cassandra-suites/delegatedidentity/03-start-agent b/test/integration/cassandra-suites/delegatedidentity/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/delegatedidentity/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/delegatedidentity/04-create-registration-entries b/test/integration/cassandra-suites/delegatedidentity/04-create-registration-entries new file mode 100755 index 0000000000..0ba8854c5c --- /dev/null +++ b/test/integration/cassandra-suites/delegatedidentity/04-create-registration-entries @@ -0,0 +1,19 @@ +#!/bin/bash + +log-debug "creating registration entry for authorized client..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/authorized_delegate" \ + -selector "unix:uid:1001" \ + -x509SVIDTTL 0 +check-synced-entry "spire-agent" "spiffe://domain.test/authorized_delegate" + +log-debug "creating registration entry for workload..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload" \ + -selector "unix:uid:1002" \ + -x509SVIDTTL 0 +check-synced-entry "spire-agent" "spiffe://domain.test/workload" diff --git a/test/integration/cassandra-suites/delegatedidentity/05-test-endpoints b/test/integration/cassandra-suites/delegatedidentity/05-test-endpoints new file mode 100755 index 0000000000..2881dfd56a --- /dev/null +++ b/test/integration/cassandra-suites/delegatedidentity/05-test-endpoints @@ -0,0 +1,9 @@ +#!/bin/bash + +log-info "Test Delegated Identity API (for success)" +docker compose exec -u 1001 -T spire-agent \ + /opt/spire/conf/agent/delegatedidentityclient -expectedID spiffe://domain.test/workload || fail-now "Failed to check Delegated Identity API" + +log-info "Test Delegated Identity API (expecting permission denied)" +docker compose exec -u 1002 -T spire-agent \ + /opt/spire/conf/agent/delegatedidentityclient || fail-now "Failed to check Delegated Identity API" diff --git a/test/integration/cassandra-suites/delegatedidentity/README.md b/test/integration/cassandra-suites/delegatedidentity/README.md new file mode 100644 index 0000000000..2ed60353da --- /dev/null +++ b/test/integration/cassandra-suites/delegatedidentity/README.md @@ -0,0 +1,5 @@ +# Delegated Identity API Suite + +## Description + +This suite tests the Delegated Identity API diff --git a/test/integration/cassandra-suites/delegatedidentity/conf/agent/agent.conf b/test/integration/cassandra-suites/delegatedidentity/conf/agent/agent.conf new file mode 100644 index 0000000000..1c8dc05eb5 --- /dev/null +++ b/test/integration/cassandra-suites/delegatedidentity/conf/agent/agent.conf @@ -0,0 +1,32 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" + + admin_socket_path = "/opt/admin.sock" + + authorized_delegates = [ + "spiffe://domain.test/authorized_delegate", + ] +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/delegatedidentity/conf/server/server.conf b/test/integration/cassandra-suites/delegatedidentity/conf/server/server.conf new file mode 100644 index 0000000000..2b324efd99 --- /dev/null +++ b/test/integration/cassandra-suites/delegatedidentity/conf/server/server.conf @@ -0,0 +1,48 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/delegatedidentity/docker-compose.yaml b/test/integration/cassandra-suites/delegatedidentity/docker-compose.yaml new file mode 100644 index 0000000000..d8c2241661 --- /dev/null +++ b/test/integration/cassandra-suites/delegatedidentity/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + spire-server: + image: spire-server:latest-local + env_file: + - ../.env + hostname: spire-server + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/delegatedidentity/teardown b/test/integration/cassandra-suites/delegatedidentity/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/delegatedidentity/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/downstream-endpoints/00-setup b/test/integration/cassandra-suites/downstream-endpoints/00-setup new file mode 100755 index 0000000000..07b8262f54 --- /dev/null +++ b/test/integration/cassandra-suites/downstream-endpoints/00-setup @@ -0,0 +1,6 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent + +"${ROOTDIR}/setup/downstreamclient/build.sh" "${RUNDIR}/conf/agent/downstreamclient" + diff --git a/test/integration/cassandra-suites/downstream-endpoints/01-start-server b/test/integration/cassandra-suites/downstream-endpoints/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/downstream-endpoints/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/downstream-endpoints/02-bootstrap-agent b/test/integration/cassandra-suites/downstream-endpoints/02-bootstrap-agent new file mode 100755 index 0000000000..8ee7d32c26 --- /dev/null +++ b/test/integration/cassandra-suites/downstream-endpoints/02-bootstrap-agent @@ -0,0 +1,5 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt diff --git a/test/integration/cassandra-suites/downstream-endpoints/03-start-agent b/test/integration/cassandra-suites/downstream-endpoints/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/downstream-endpoints/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/downstream-endpoints/04-create-entries b/test/integration/cassandra-suites/downstream-endpoints/04-create-entries new file mode 100755 index 0000000000..29b4d56d7e --- /dev/null +++ b/test/integration/cassandra-suites/downstream-endpoints/04-create-entries @@ -0,0 +1,21 @@ +#!/bin/bash + +log-debug "creating downstream registration entry..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/downstream" \ + -selector "unix:uid:1001" \ + -downstream \ + -x509SVIDTTL 0 +check-synced-entry "spire-agent" "spiffe://domain.test/downstream" + +log-debug "creating workload registration entry..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload" \ + -selector "unix:uid:1002" \ + -x509SVIDTTL 0 +check-synced-entry "spire-agent" "spiffe://domain.test/workload" + diff --git a/test/integration/cassandra-suites/downstream-endpoints/05-test-endpoints b/test/integration/cassandra-suites/downstream-endpoints/05-test-endpoints new file mode 100755 index 0000000000..185501fec0 --- /dev/null +++ b/test/integration/cassandra-suites/downstream-endpoints/05-test-endpoints @@ -0,0 +1,9 @@ +#!/bin/bash + +log-debug "test downstream workload..." +docker compose exec -u 1001 -T spire-agent \ + /opt/spire/conf/agent/downstreamclient || fail-now "failed to check downstream endpoints" + +log-debug "Test regular workload..." +docker compose exec -u 1002 -T spire-agent \ + /opt/spire/conf/agent/downstreamclient -expectErrors || fail-now "failed to check permission errors on downstream endpoints" diff --git a/test/integration/cassandra-suites/downstream-endpoints/README.md b/test/integration/cassandra-suites/downstream-endpoints/README.md new file mode 100644 index 0000000000..4c9450d277 --- /dev/null +++ b/test/integration/cassandra-suites/downstream-endpoints/README.md @@ -0,0 +1,5 @@ +# Downstream Endpoint Suite + +## Description + +The suite validates access to Downstream RPCs using downstream workload and regular workloads diff --git a/test/integration/cassandra-suites/downstream-endpoints/conf/agent/agent.conf b/test/integration/cassandra-suites/downstream-endpoints/conf/agent/agent.conf new file mode 100644 index 0000000000..f79c4e9b06 --- /dev/null +++ b/test/integration/cassandra-suites/downstream-endpoints/conf/agent/agent.conf @@ -0,0 +1,26 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/downstream-endpoints/conf/server/server.conf b/test/integration/cassandra-suites/downstream-endpoints/conf/server/server.conf new file mode 100644 index 0000000000..2b324efd99 --- /dev/null +++ b/test/integration/cassandra-suites/downstream-endpoints/conf/server/server.conf @@ -0,0 +1,48 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/downstream-endpoints/docker-compose.yaml b/test/integration/cassandra-suites/downstream-endpoints/docker-compose.yaml new file mode 100644 index 0000000000..808ddfb610 --- /dev/null +++ b/test/integration/cassandra-suites/downstream-endpoints/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + spire-server: + image: spire-server:latest-local + hostname: spire-server + env_file: + - ../.env + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/downstream-endpoints/teardown b/test/integration/cassandra-suites/downstream-endpoints/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/downstream-endpoints/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/entries/00-setup b/test/integration/cassandra-suites/entries/00-setup new file mode 100755 index 0000000000..4827a4edfe --- /dev/null +++ b/test/integration/cassandra-suites/entries/00-setup @@ -0,0 +1,13 @@ +#!/bin/bash + +EVENTS_BASED_CACHE=false +if [[ "${TESTNAME}" == "events-based-entries" ]]; then + EVENTS_BASED_CACHE=true +fi +sed -i.bak "s#EVENTS_BASED_CACHE#${EVENTS_BASED_CACHE}#g" conf/server/server.conf + +"${ROOTDIR}/setup/x509pop/setup.sh" -trust-domain domain.test -x509pop-san "cluster/test" conf/server conf/agent1 conf/agent2 + +"${ROOTDIR}/setup/debugserver/build.sh" "${RUNDIR}/conf/server/debugclient" +"${ROOTDIR}/setup/debugagent/build.sh" "${RUNDIR}/conf/agent1/debugclient" +"${ROOTDIR}/setup/debugagent/build.sh" "${RUNDIR}/conf/agent2/debugclient" diff --git a/test/integration/cassandra-suites/entries/01-start-server b/test/integration/cassandra-suites/entries/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/entries/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/entries/02-start-agents b/test/integration/cassandra-suites/entries/02-start-agents new file mode 100755 index 0000000000..3873648d7b --- /dev/null +++ b/test/integration/cassandra-suites/entries/02-start-agents @@ -0,0 +1,63 @@ +#!/bin/bash + +log-debug "Creating bootstrap bundle..." +for agentID in $(seq 1 3); do + docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent${agentID}/bootstrap.crt +done + +log-info "generating join token..." +TOKEN=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server token generate -spiffeID spiffe://domain.test/cluster/test | awk '{print $2}' | tr -d '\r') + +# Inserts the join token into the agent configuration +log-debug "using join token ${TOKEN}..." +sed -i.bak "s#TOKEN#${TOKEN}#g" conf/agent3/agent.conf + +docker-up spire-agent-1 spire-agent-2 spire-agent-3 + +log-debug "Creating node-alias for x509pop:san:cluster:test" +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/server" \ + -spiffeID "spiffe://domain.test/cluster/test" \ + -selector "x509pop:san:cluster:test" \ + -node + +log-debug "Creating registration entries" +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -entryID agent-1 \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent1/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload-agent-1" \ + -selector "unix:uid:1001" + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -entryID agent-2 \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent2/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload-agent-2" \ + -selector "unix:uid:1001" + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -entryID shared \ + -parentID "spiffe://domain.test/cluster/test" \ + -spiffeID "spiffe://domain.test/workload-shared" \ + -selector "unix:uid:1002" + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -entryID with-dns \ + -parentID "spiffe://domain.test/cluster/test" \ + -spiffeID "spiffe://domain.test/workload-with-dns" \ + -selector "unix:uid:1003" \ + -dns "example.org" + +check-synced-entry "spire-agent-1" "spiffe://domain.test/workload-agent-1" +check-synced-entry "spire-agent-2" "spiffe://domain.test/workload-agent-2" + +for agent in spire-agent-1 spire-agent-2 spire-agent-3; do + check-synced-entry ${agent} "spiffe://domain.test/workload-shared" + check-synced-entry ${agent} "spiffe://domain.test/workload-with-dns" +done diff --git a/test/integration/cassandra-suites/entries/03-fetch-svids b/test/integration/cassandra-suites/entries/03-fetch-svids new file mode 100755 index 0000000000..228e6ca840 --- /dev/null +++ b/test/integration/cassandra-suites/entries/03-fetch-svids @@ -0,0 +1,24 @@ +#!/bin/bash + +log-debug "Check SVIDs issues from created registration entries" +docker compose exec -u 1001 -T spire-agent-1 \ + /opt/spire/bin/spire-agent api fetch x509 -output json \ + -socketPath /opt/spire/sockets/workload_api.sock | jq --exit-status -r '.svids[0].spiffe_id == "spiffe://domain.test/workload-agent-1"' + +docker compose exec -u 1001 -T spire-agent-2 \ + /opt/spire/bin/spire-agent api fetch x509 -output json \ + -socketPath /opt/spire/sockets/workload_api.sock | jq --exit-status -r '.svids[0].spiffe_id == "spiffe://domain.test/workload-agent-2"' + +for agent in spire-agent-1 spire-agent-2 spire-agent-3; do + docker compose exec -u 1002 -T ${agent} \ + /opt/spire/bin/spire-agent api fetch x509 -output json \ + -socketPath /opt/spire/sockets/workload_api.sock | jq --exit-status -r '.svids[0].spiffe_id == "spiffe://domain.test/workload-shared"' + + log-debug "Check that issued SVID has DNS name set for agent '${agent}'" + docker compose exec -u 1003 -T ${agent} \ + /opt/spire/bin/spire-agent api fetch x509 -output json -socketPath /opt/spire/sockets/workload_api.sock | \ + jq -r '.svids[0].x509_svid' | \ + base64 -d | \ + openssl x509 -inform der -text -noout | \ + grep -q "DNS:example.org" +done diff --git a/test/integration/cassandra-suites/entries/04-update-entry b/test/integration/cassandra-suites/entries/04-update-entry new file mode 100755 index 0000000000..ea1b6b12d2 --- /dev/null +++ b/test/integration/cassandra-suites/entries/04-update-entry @@ -0,0 +1,38 @@ +#!/bin/bash + +log-debug "Updating an entry and verifying the change propagates to agents" +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry update \ + -entryID with-dns \ + -parentID "spiffe://domain.test/cluster/test" \ + -spiffeID "spiffe://domain.test/workload-with-dns" \ + -selector "unix:uid:1003" \ + -dns "example.com" + +MAXCHECKS=30 +INTERVAL=1 +SVID_STALE=0 +for ((i=1;i<=MAXCHECKS;i++)); do + SVID_STALE=0 + for agent in spire-agent-1 spire-agent-2 spire-agent-3; do + if ! docker compose exec -u 1003 -T ${agent} \ + /opt/spire/bin/spire-agent api fetch x509 -output json -socketPath /opt/spire/sockets/workload_api.sock | \ + jq -r '.svids[0].x509_svid' | \ + base64 -d | \ + openssl x509 -inform der -text -noout | \ + grep -q "DNS:example.com"; then + log-debug "Entry update did not propagate to agent '${agent}'" + SVID_STALE=1 + fi + done + + if [[ ${SVID_STALE} == 0 ]]; then + break + fi + + sleep ${INTERVAL} +done + +if [[ ${SVID_STALE} == 1 ]]; then + fail-now "Entry update did not propagate to all agents" +fi diff --git a/test/integration/cassandra-suites/entries/05-delete-entries b/test/integration/cassandra-suites/entries/05-delete-entries new file mode 100755 index 0000000000..8b211fb2a0 --- /dev/null +++ b/test/integration/cassandra-suites/entries/05-delete-entries @@ -0,0 +1,24 @@ +#!/bin/bash + +wait-for-svid-failure() { + local MAXCHECKS=30 + local INTERVAL=1 + for ((i=1;i<=MAXCHECKS;i++)); do + if ! docker compose exec -u 1003 -T ${1} \ + /opt/spire/bin/spire-agent api fetch x509 -output json -socketPath /opt/spire/sockets/workload_api.sock; then + log-debug "Could not fetch X509-SVID for deleted entry, as expected." + return 0 + fi + sleep ${INTERVAL} + done + + fail-now "Entry was not deleted from agent." +} + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry delete \ + -entryID with-dns + +for agent in spire-agent-1 spire-agent-2 spire-agent-3; do + wait-for-svid-failure ${agent} +done diff --git a/test/integration/cassandra-suites/entries/README.md b/test/integration/cassandra-suites/entries/README.md new file mode 100644 index 0000000000..ff7c5b9343 --- /dev/null +++ b/test/integration/cassandra-suites/entries/README.md @@ -0,0 +1,10 @@ +# Registration Entries Suite + +This suites verifies: + +* Registration entry propagation to agents via node aliases. +* Registration entry via join token "alias". +* Registration entry update propagation. + +The test can optionally be run via the `events-based-entries` suite, which will run the same +tests but with the events based cache enabled. diff --git a/test/integration/cassandra-suites/entries/conf/agent1/agent.conf b/test/integration/cassandra-suites/entries/conf/agent1/agent.conf new file mode 100644 index 0000000000..3beb6fc476 --- /dev/null +++ b/test/integration/cassandra-suites/entries/conf/agent1/agent.conf @@ -0,0 +1,29 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" + admin_socket_path = "/opt/debug.sock" + x509_svid_cache_max_size = 8 +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/entries/conf/agent2/agent.conf b/test/integration/cassandra-suites/entries/conf/agent2/agent.conf new file mode 100644 index 0000000000..33dcc84468 --- /dev/null +++ b/test/integration/cassandra-suites/entries/conf/agent2/agent.conf @@ -0,0 +1,30 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" + admin_socket_path = "/opt/debug.sock" + x509_svid_cache_max_size = 8 +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} + diff --git a/test/integration/cassandra-suites/entries/conf/agent3/agent.conf b/test/integration/cassandra-suites/entries/conf/agent3/agent.conf new file mode 100644 index 0000000000..2bfbe602dc --- /dev/null +++ b/test/integration/cassandra-suites/entries/conf/agent3/agent.conf @@ -0,0 +1,28 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" + + # The TOKEN is replaced with the actual token generated by SPIRE server + # during the test run. + join_token = "TOKEN" +} + +plugins { + NodeAttestor "join_token" { + plugin_data { + } + } + KeyManager "memory" { + plugin_data { + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/entries/conf/server/server.conf b/test/integration/cassandra-suites/entries/conf/server/server.conf new file mode 100644 index 0000000000..51e02d7a12 --- /dev/null +++ b/test/integration/cassandra-suites/entries/conf/server/server.conf @@ -0,0 +1,52 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + + experimental { + events_based_cache = EVENTS_BASED_CACHE + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + NodeAttestor "join_token" { + plugin_data {} + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/entries/docker-compose.yaml b/test/integration/cassandra-suites/entries/docker-compose.yaml new file mode 100644 index 0000000000..6db90e9b71 --- /dev/null +++ b/test/integration/cassandra-suites/entries/docker-compose.yaml @@ -0,0 +1,38 @@ +services: + spire-server: + image: spire-server:latest-local + env_file: + - ../.env + hostname: spire-server + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent-1: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent-1 + depends_on: ["spire-server"] + volumes: + - ./conf/agent1:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + spire-agent-2: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent-2 + depends_on: ["spire-server"] + volumes: + - ./conf/agent2:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + spire-agent-3: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent-3 + depends_on: ["spire-server"] + volumes: + - ./conf/agent3:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/entries/teardown b/test/integration/cassandra-suites/entries/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/entries/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/00-test-envoy-releases.sh b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/00-test-envoy-releases.sh new file mode 100755 index 0000000000..909713f247 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/00-test-envoy-releases.sh @@ -0,0 +1,177 @@ +#!/bin/bash + +setup-tests() { + # Bring up servers + docker-spire-server-up upstream-spire-server downstream-federated-spire-server + + # Bootstrap agents + log-debug "bootstrapping downstream federated agent..." + docker compose exec -T downstream-federated-spire-server \ + /opt/spire/bin/spire-server bundle show > conf/downstream-federated/agent/bootstrap.crt + + log-debug "bootstrapping upstream agent..." + docker compose exec -T upstream-spire-server \ + /opt/spire/bin/spire-server bundle show > conf/upstream/agent/bootstrap.crt + + docker compose exec -T upstream-spire-server \ + /opt/spire/bin/spire-server bundle show > conf/downstream/agent/bootstrap.crt + + log-debug "creating federation relationship from downstream federated to upstream server and set bundle in same command..." + docker compose exec -T downstream-federated-spire-server \ + /opt/spire/bin/spire-server bundle show -format spiffe > conf/upstream/server/federated-domain.test.bundle + + # On macOS, there can be a delay propagating the file on the bind mount to the other container + sleep 1 + + docker compose exec -T upstream-spire-server \ + /opt/spire/bin/spire-server federation create \ + -bundleEndpointProfile "https_spiffe" \ + -bundleEndpointURL "https://downstream-federated-spire-server:8443" \ + -endpointSpiffeID "spiffe://federated-domain.test/spire/server" \ + -trustDomain "federated-domain.test" \ + -trustDomainBundleFormat "spiffe" \ + -trustDomainBundlePath "/opt/spire/conf/server/federated-domain.test.bundle" + + log-debug "bootstrapping bundle from upstream to downstream federated server..." + docker compose exec -T upstream-spire-server \ + /opt/spire/bin/spire-server bundle show -format spiffe > conf/downstream-federated/server/domain.test.bundle + + # On macOS, there can be a delay propagating the file on the bind mount to the other container + sleep 1 + + docker compose exec -T downstream-federated-spire-server \ + /opt/spire/bin/spire-server bundle set -format spiffe -id spiffe://domain.test -path /opt/spire/conf/server/domain.test.bundle + + log-debug "creating federation relationship from upstream to downstream federated server..." + docker compose exec -T downstream-federated-spire-server \ + /opt/spire/bin/spire-server federation create \ + -bundleEndpointProfile "https_spiffe" \ + -bundleEndpointURL "https://upstream-spire-server" \ + -endpointSpiffeID "spiffe://domain.test/spire/server" \ + -trustDomain "spiffe://domain.test" + + # Register workloads + log-debug "creating registration entry for downstream federated proxy..." + docker compose exec -T downstream-federated-spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://federated-domain.test/spire/agent/x509pop/$(fingerprint conf/downstream-federated/agent/agent.crt.pem)" \ + -spiffeID "spiffe://federated-domain.test/downstream-proxy" \ + -selector "unix:uid:0" \ + -federatesWith "spiffe://domain.test" \ + -x509SVIDTTL 0 + + log-debug "creating registration entry for upstream proxy..." + docker compose exec -T upstream-spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/upstream/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/upstream-proxy" \ + -selector "unix:uid:0" \ + -federatesWith "spiffe://federated-domain.test" \ + -x509SVIDTTL 0 + + log-debug "creating registration entry for downstream proxy..." + docker compose exec -T upstream-spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/downstream/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/downstream-proxy" \ + -selector "unix:uid:0" \ + -x509SVIDTTL 0 +} + +test-envoy() { + mTLSSocat=$1 + tlsSocat=$2 + + local max_checks_per_port=15 + local check_interval=1 + + # Remove howdy, it i necessary for VERIFY to get again messages + docker compose exec -T upstream-socat rm -f /tmp/howdy + + log-debug "Checking mTLS: ${mTLSSocat}" + TRY() { docker compose exec -T ${mTLSSocat} /bin/sh -c 'echo HELLO_MTLS | socat -u STDIN TCP:localhost:8001'; } + VERIFY() { docker compose exec -T upstream-socat cat /tmp/howdy | grep -q HELLO_MTLS; } + + local mtls_federated_ok= + for ((i=1;i<=max_checks_per_port;i++)); do + log-debug "Checking MTLS proxy ($i of $max_checks_per_port max)..." + if TRY && VERIFY ; then + mtls_federated_ok=1 + log-info "MTLS proxy OK" + break + fi + sleep "${check_interval}" + done + + log-debug "Checking TLS: ${tlsSocat}" + TRY() { docker compose exec -T ${tlsSocat} /bin/sh -c 'echo HELLO_TLS | socat -u STDIN TCP:localhost:8002'; } + VERIFY() { docker compose exec -T upstream-socat cat /tmp/howdy | grep -q HELLO_TLS; } + + tls_federated_ok= + for ((i=1;i<=max_checks_per_port;i++)); do + log-debug "Checking TLS proxy ($i of $max_checks_per_port max)..." + if TRY && VERIFY ; then + tls_federated_ok=1 + log-info "TLS proxy OK" + break + fi + sleep "${check_interval}" + done + + if [ -z "${mtls_federated_ok}" ]; then + log-warn "MTLS proxy check failed for ${mTLSSocat} after ${max_checks_per_port} attempts" + dump-multiple-container-logs upstream-proxy downstream-proxy downstream-federated-proxy upstream-socat downstream-socat-mtls downstream-socat-tls downstream-federated-socat-mtls downstream-federated-socat-tls + fail-now "MTLS Proxying failed" + fi + + if [ -z "${tls_federated_ok}" ]; then + log-warn "TLS proxy check failed for ${tlsSocat} after ${max_checks_per_port} attempts" + dump-multiple-container-logs upstream-proxy downstream-proxy downstream-federated-proxy upstream-socat downstream-socat-mtls downstream-socat-tls downstream-federated-socat-mtls downstream-federated-socat-tls + fail-now "TLS Proxying failed" + fi +} + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/downstream-federated/server conf/downstream-federated/agent +"${ROOTDIR}/setup/x509pop/setup.sh" conf/upstream/server conf/upstream/agent conf/downstream/agent + +# Test at most the last five minor releases. +MAX_ENVOY_RELEASES_TO_TEST=5 + +# Don't test earlier than v1.13, when was the first release to include the v3 +# API. +EARLIEST_ENVOY_RELEASE_TO_TEST=v1.18 + +envoy-releases + +log-info "Releases to test: ${ENVOY_RELEASES_TO_TEST[@]}" + +# Do some preliminary setup +setup-tests + +# Execute the tests for each release under test. The spire-server should remain +# up across these tests to minimize teardown/setup costs that are tangential +# to the support (since we're only testing the SDS integration). +for release in "${ENVOY_RELEASES_TO_TEST[@]}"; do + log-info "Building Envoy ${release}..." + build-mashup-image "${release}" + + log-info "Testing Envoy ${release}..." + + docker-up + + test-envoy "downstream-socat-mtls" "downstream-socat-tls" + test-envoy "downstream-federated-socat-mtls" "downstream-federated-socat-tls" + + # stop and clear everything but the server container + docker compose stop \ + upstream-proxy \ + downstream-proxy \ + downstream-federated-proxy \ + upstream-socat \ + downstream-socat-mtls \ + downstream-socat-tls \ + downstream-federated-socat-mtls \ + downstream-federated-socat-tls + + docker compose rm -f +done diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/Dockerfile b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/Dockerfile new file mode 100644 index 0000000000..6933af0cc7 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/Dockerfile @@ -0,0 +1,9 @@ +FROM spire-agent:latest-local AS spire-agent + +FROM envoyproxy/envoy-alpine:v1.19.0 AS envoy-agent-mashup +COPY --from=spire-agent /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent +RUN apk --no-cache add dumb-init +RUN apk --no-cache add supervisor +COPY conf/supervisord.conf /etc/ +ENTRYPOINT ["/usr/bin/dumb-init", "supervisord", "--nodaemon", "--configuration", "/etc/supervisord.conf"] +CMD [] diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/README.md b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/README.md new file mode 100644 index 0000000000..ef76b278ee --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/README.md @@ -0,0 +1,22 @@ +# Envoy SDS v3 SPIFFE Auth Suite + +## Description + +Exercises [Envoy](https://www.envoyproxy.io/) +[SDS](https://www.envoyproxy.io/docs/envoy/latest/configuration/security/secret) +compatibility within SPIRE by wiring up two workloads that achieve connectivity +using Envoy backed with identities and trust information retrieved from the +SPIRE agent SDS implementation. Using [SPIFFE Validator](https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/transport_sockets/tls/v3/tls_spiffe_validator_config.proto) +for certificates handshake. + +A customer container image is used that runs both Envoy and the SPIRE Agent. Socat containers are used as the workload. + +The test ensures both TLS and mTLS connectivity between the workload. This is exercised with a federated workload and also with a not federated workload. + + upstream-spire-server downtream-federated-spire-server + / \ | + / \ | + downtream-proxy upstream-proxy downstream-federated-proxy + / \ | / \ + | | | | | + downtream-socat-mtls downstream-socat-tls upstream-socat downstream-federated-socat-mtls downstream-federated-socat-tls diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream-federated/agent/agent.conf b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream-federated/agent/agent.conf new file mode 100644 index 0000000000..f75c119842 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream-federated/agent/agent.conf @@ -0,0 +1,28 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "downstream-federated-spire-server" + server_port = "8081" + socket_path ="/opt/shared/agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "federated-domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} + diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream-federated/envoy/envoy.yaml b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream-federated/envoy/envoy.yaml new file mode 100644 index 0000000000..42d21373f7 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream-federated/envoy/envoy.yaml @@ -0,0 +1,125 @@ +node: + id: "downstream-federated-envoy" + cluster: "test" +static_resources: + listeners: + - name: downstream_to_upstream_mtls_listener + address: + socket_address: + address: 0.0.0.0 + port_value: 8001 + filter_chains: + - filters: + - name: envoy.tcp_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: downstream_to_upstream_mtls + stat_prefix: downstream_to_upstream_mtls + - name: downstream_to_upstream_tls_listener + address: + socket_address: + address: 0.0.0.0 + port_value: 8002 + filter_chains: + - filters: + - name: envoy.tcp_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: downstream_to_upstream_tls + stat_prefix: downstream_to_upstream_tls + clusters: + - name: spire_agent + connect_timeout: 0.25s + http2_protocol_options: {} + load_assignment: + cluster_name: spire_agent + endpoints: + - lb_endpoints: + - endpoint: + address: + pipe: + path: /opt/shared/agent.sock + - name: downstream_to_upstream_mtls + connect_timeout: 0.25s + type: strict_dns + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: downstream_to_upstream_mtls + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: upstream-proxy + port_value: 8001 + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificate_sds_secret_configs: + - name: "spiffe://federated-domain.test/downstream-proxy" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + combined_validation_context: + default_validation_context: + match_typed_subject_alt_names: + - san_type: URI + matcher: + exact: "spiffe://domain.test/upstream-proxy" + validation_context_sds_secret_config: + name: "ALL" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + tls_params: + ecdh_curves: + - X25519:P-256:P-521:P-384 + - name: downstream_to_upstream_tls + connect_timeout: 0.25s + type: strict_dns + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: downstream_to_upstream_tls + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: upstream-proxy + port_value: 8002 + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + combined_validation_context: + default_validation_context: + match_typed_subject_alt_names: + - san_type: URI + matcher: + exact: "spiffe://domain.test/upstream-proxy" + validation_context_sds_secret_config: + name: "ALL" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + tls_params: + ecdh_curves: + - X25519:P-256:P-521:P-384 diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream-federated/server/server.conf b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream-federated/server/server.conf new file mode 100644 index 0000000000..10dca0caef --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream-federated/server/server.conf @@ -0,0 +1,56 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "federated-domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "5m" + + federation { + bundle_endpoint { + port = 8443 + } + } + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire1" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "disk" { + plugin_data = { + keys_path = "/opt/spire/data/server/keys.json" + } + } +} diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream/agent/agent.conf b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream/agent/agent.conf new file mode 100644 index 0000000000..70614db90b --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream/agent/agent.conf @@ -0,0 +1,30 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + # TODO: remove it + /* log_file = "/opt/spire/agent.log" */ + server_address = "upstream-spire-server" + server_port = "8081" + socket_path ="/opt/shared/agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} + diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream/envoy/envoy.yaml b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream/envoy/envoy.yaml new file mode 100644 index 0000000000..3b38ce4d7b --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/downstream/envoy/envoy.yaml @@ -0,0 +1,125 @@ +node: + id: "downstream-envoy" + cluster: "test" +static_resources: + listeners: + - name: downstream_to_upstream_mtls_listener + address: + socket_address: + address: 0.0.0.0 + port_value: 8001 + filter_chains: + - filters: + - name: envoy.tcp_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: downstream_to_upstream_mtls + stat_prefix: downstream_to_upstream_mtls + - name: downstream_to_upstream_tls_listener + address: + socket_address: + address: 0.0.0.0 + port_value: 8002 + filter_chains: + - filters: + - name: envoy.tcp_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: downstream_to_upstream_tls + stat_prefix: downstream_to_upstream_tls + clusters: + - name: spire_agent + connect_timeout: 0.25s + http2_protocol_options: {} + load_assignment: + cluster_name: spire_agent + endpoints: + - lb_endpoints: + - endpoint: + address: + pipe: + path: /opt/shared/agent.sock + - name: downstream_to_upstream_mtls + connect_timeout: 0.25s + type: strict_dns + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: downstream_to_upstream_mtls + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: upstream-proxy + port_value: 8001 + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificate_sds_secret_configs: + - name: "spiffe://domain.test/downstream-proxy" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + combined_validation_context: + default_validation_context: + match_typed_subject_alt_names: + - san_type: URI + matcher: + exact: "spiffe://domain.test/upstream-proxy" + validation_context_sds_secret_config: + name: "spiffe://domain.test" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + tls_params: + ecdh_curves: + - X25519:P-256:P-521:P-384 + - name: downstream_to_upstream_tls + connect_timeout: 0.25s + type: strict_dns + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: downstream_to_upstream_tls + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: upstream-proxy + port_value: 8002 + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + combined_validation_context: + default_validation_context: + match_typed_subject_alt_names: + - san_type: URI + matcher: + exact: "spiffe://domain.test/upstream-proxy" + validation_context_sds_secret_config: + name: "spiffe://domain.test" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + tls_params: + ecdh_curves: + - X25519:P-256:P-521:P-384 diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/supervisord.conf b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/supervisord.conf new file mode 100644 index 0000000000..4b42626c42 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/supervisord.conf @@ -0,0 +1,10 @@ +[supervisord] +nodaemon=true +loglevel=debug + +[program:spire-agent] +command = /opt/spire/bin/spire-agent run -config /opt/spire/conf/agent/agent.conf + +[program:envoy] +command = /usr/local/bin/envoy -l debug -c /opt/envoy/conf/envoy.yaml + diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/upstream/agent/agent.conf b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/upstream/agent/agent.conf new file mode 100644 index 0000000000..eec4c14acb --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/upstream/agent/agent.conf @@ -0,0 +1,28 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "upstream-spire-server" + server_port = "8081" + socket_path ="/opt/shared/agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} + diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/upstream/envoy/envoy.yaml b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/upstream/envoy/envoy.yaml new file mode 100644 index 0000000000..bce035abe2 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/upstream/envoy/envoy.yaml @@ -0,0 +1,107 @@ +node: + id: "upstream-envoy" + cluster: "test" +static_resources: + listeners: + - name: listener-sds-mtls + address: + socket_address: + address: 0.0.0.0 + port_value: 8001 + filter_chains: + - filters: + - name: envoy.tcp_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: upstream_socat + stat_prefix: upstream_socat_mtls + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext + common_tls_context: + tls_certificate_sds_secret_configs: + - name: "spiffe://domain.test/upstream-proxy" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + combined_validation_context: + default_validation_context: + match_subject_alt_names: + safe_regex: + google_re2: + max_program_size: 100 + regex: "spiffe://(federated-domain|domain)\\.test/downstream-proxy" + validation_context_sds_secret_config: + name: "ALL" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + tls_params: + ecdh_curves: + - X25519:P-256:P-521:P-384 + - name: listener-sds-tls + address: + socket_address: + address: 0.0.0.0 + port_value: 8002 + filter_chains: + - filters: + - name: envoy.tcp_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: upstream_socat + stat_prefix: upstream_socat_tls + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext + common_tls_context: + tls_certificate_sds_secret_configs: + - name: "spiffe://domain.test/upstream-proxy" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + tls_params: + ecdh_curves: + - X25519:P-256:P-521:P-384 + clusters: + - name: spire_agent + connect_timeout: 0.25s + http2_protocol_options: {} + load_assignment: + cluster_name: spire_agent + endpoints: + - lb_endpoints: + - endpoint: + address: + pipe: + path: /opt/shared/agent.sock + - name: upstream_socat + connect_timeout: 0.25s + type: strict_dns + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: upstream_socat + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: upstream-socat + port_value: 8000 diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/upstream/server/server.conf b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/upstream/server/server.conf new file mode 100644 index 0000000000..abd2885074 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/conf/upstream/server/server.conf @@ -0,0 +1,56 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "5m" + + federation { + bundle_endpoint { + port = 8443 + } + } + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "disk" { + plugin_data = { + keys_path = "/opt/spire/data/server/keys.json" + } + } +} diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/docker-compose.yaml b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/docker-compose.yaml new file mode 100644 index 0000000000..83653fd839 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/docker-compose.yaml @@ -0,0 +1,90 @@ +services: + upstream-spire-server: + image: spire-server:latest-local + env_file: + - ../.env + hostname: upstream-spire-server + volumes: + - ./conf/upstream/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + downstream-federated-spire-server: + image: spire-server:latest-local + env_file: + - ../.env + hostname: downstream-federated-spire-server + volumes: + - ./conf/downstream-federated/server:/opt/spire/conf/server + command: ["config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + upstream-proxy: + image: envoy-agent-mashup + env_file: + - ../.env + hostname: upstream-proxy + depends_on: ["upstream-spire-server", "upstream-socat"] + volumes: + - ./conf/upstream/agent:/opt/spire/conf/agent + - ./conf/upstream/envoy:/opt/envoy/conf + downstream-proxy: + image: envoy-agent-mashup + env_file: + - ../.env + hostname: downstream-proxy + depends_on: ["upstream-spire-server", "upstream-proxy"] + volumes: + - ./conf/downstream/agent:/opt/spire/conf/agent + - ./conf/downstream/envoy:/opt/envoy/conf + downstream-federated-proxy: + image: envoy-agent-mashup + env_file: + - ../.env + hostname: downstream-federated-proxy + depends_on: ["downstream-federated-spire-server", "upstream-proxy"] + volumes: + - ./conf/downstream-federated/agent:/opt/spire/conf/agent + - ./conf/downstream-federated/envoy:/opt/envoy/conf + upstream-socat: + image: alpine/socat:latest + env_file: + - ../.env + hostname: upstream-socat + command: ["-d", "-d", "TCP-LISTEN:8000,fork", "OPEN:\"/tmp/howdy\",creat,append"] + downstream-socat-mtls: + image: alpine/socat:latest + env_file: + - ../.env + hostname: downstream-socat-mtls + restart: on-failure + depends_on: ["downstream-proxy"] + tty: true + command: ["-d", "-d", "TCP-LISTEN:8001,fork", "TCP:downstream-proxy:8001"] + downstream-socat-tls: + image: alpine/socat:latest + env_file: + - ../.env + hostname: downstream-socat-tls + restart: on-failure + depends_on: ["downstream-proxy"] + tty: true + command: ["-d", "-d", "TCP-LISTEN:8002,fork", "TCP:downstream-proxy:8002"] + downstream-federated-socat-mtls: + image: alpine/socat:latest + env_file: + - ../.env + hostname: downstream-federated-socat-mtls + restart: on-failure + depends_on: ["downstream-federated-proxy"] + tty: true + command: ["-d", "-d", "TCP-LISTEN:8001,fork", "TCP:downstream-federated-proxy:8001"] + downstream-federated-socat-tls: + image: alpine/socat:latest + env_file: + - ../.env + hostname: downstream-federated-socat-tls + restart: on-failure + depends_on: ["downstream-federated-proxy"] + tty: true + command: ["-d", "-d", "TCP-LISTEN:8002,fork", "TCP:downstream-federated-proxy:8002"] diff --git a/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/teardown b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/teardown new file mode 100755 index 0000000000..c5028ad9d3 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3-spiffe-auth/teardown @@ -0,0 +1,7 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down +docker exec cassandra-1 cqlsh localhost 9044 -e "DROP KEYSPACE IF EXISTS spire1;" diff --git a/test/integration/cassandra-suites/envoy-sds-v3/00-test-envoy-releases b/test/integration/cassandra-suites/envoy-sds-v3/00-test-envoy-releases new file mode 100755 index 0000000000..5bc6082b94 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3/00-test-envoy-releases @@ -0,0 +1,147 @@ +#!/bin/bash + +setup-tests() { + # Bring up the server + docker-spire-server-up spire-server + + # Bootstrap the agent + log-debug "bootstrapping downstream agent..." + docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/downstream-agent/bootstrap.crt + + log-debug "bootstrapping upstream agent..." + docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/upstream-agent/bootstrap.crt + + # Register the workload + log-debug "creating registration entry for upstream workload..." + docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/upstream-agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/upstream-workload" \ + -selector "unix:uid:0" \ + -x509SVIDTTL 0 + + log-debug "creating registration entry for downstream workload..." + docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/downstream-agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/downstream-workload" \ + -selector "unix:uid:0" \ + -x509SVIDTTL 0 +} + +test-envoy() { + # Ensure connectivity for both TLS and mTLS + + MAXCHECKSPERPORT=30 + CHECKINTERVAL=1 + + TRY() { docker compose exec -T downstream-socat-mtls /bin/sh -c 'echo HELLO_MTLS | socat -u STDIN TCP:localhost:8001'; } + VERIFY() { docker compose exec -T upstream-socat cat /tmp/howdy | grep -q HELLO_MTLS; } + + MTLS_OK= + for ((i=1;i<=MAXCHECKSPERPORT;i++)); do + log-debug "Checking MTLS proxy ($i of $MAXCHECKSPERPORT max)..." + if TRY && VERIFY ; then + MTLS_OK=1 + log-info "MTLS proxy OK (succeeded after $i attempts)" + break + fi + sleep "${CHECKINTERVAL}" + done + + TRY() { docker compose exec -T downstream-socat-tls /bin/sh -c 'echo HELLO_TLS | socat -u STDIN TCP:localhost:8002'; } + VERIFY() { docker compose exec -T upstream-socat cat /tmp/howdy | grep -q HELLO_TLS; } + + TLS_OK= + for ((i=1;i<=MAXCHECKSPERPORT;i++)); do + log-debug "Checking TLS proxy ($i of $MAXCHECKSPERPORT max)..." + if TRY && VERIFY ; then + TLS_OK=1 + log-info "TLS proxy OK (succeeded after $i attempts)" + break + fi + sleep "${CHECKINTERVAL}" + done + + if [ -z "${MTLS_OK}" ]; then + log-warn "MTLS proxy check failed after ${MAXCHECKSPERPORT} attempts" + + # Dump Envoy configuration for diagnostics + log-warn "=== Upstream Envoy Listener Configuration ===" + docker compose exec -T upstream-proxy curl -s http://localhost:9901/config_dump | jq '.configs[] | select(.["@type"] | contains("Listener"))' || true + + log-warn "=== Downstream Envoy Listener Configuration ===" + docker compose exec -T downstream-proxy curl -s http://localhost:9901/config_dump | jq '.configs[] | select(.["@type"] | contains("Listener"))' || true + + log-warn "=== Upstream Envoy Stats ===" + docker compose exec -T upstream-proxy curl -s http://localhost:9901/stats | grep -E "(sds|cluster|listener)" || true + + log-warn "=== Downstream Envoy Stats ===" + docker compose exec -T downstream-proxy curl -s http://localhost:9901/stats | grep -E "(sds|cluster|listener)" || true + + dump-multiple-container-logs upstream-proxy downstream-proxy upstream-socat downstream-socat-mtls downstream-socat-tls + fail-now "MTLS Proxying failed" + fi + + if [ -z "${TLS_OK}" ]; then + log-warn "TLS proxy check failed after ${MAXCHECKSPERPORT} attempts" + + # Dump Envoy configuration for diagnostics + log-warn "=== Upstream Envoy Listener Configuration ===" + docker compose exec -T upstream-proxy curl -s http://localhost:9901/config_dump | jq '.configs[] | select(.["@type"] | contains("Listener"))' || true + + log-warn "=== Downstream Envoy Listener Configuration ===" + docker compose exec -T downstream-proxy curl -s http://localhost:9901/config_dump | jq '.configs[] | select(.["@type"] | contains("Listener"))' || true + + log-warn "=== Upstream Envoy Stats ===" + docker compose exec -T upstream-proxy curl -s http://localhost:9901/stats | grep -E "(sds|cluster|listener)" || true + + log-warn "=== Downstream Envoy Stats ===" + docker compose exec -T downstream-proxy curl -s http://localhost:9901/stats | grep -E "(sds|cluster|listener)" || true + + dump-multiple-container-logs upstream-proxy downstream-proxy upstream-socat downstream-socat-mtls downstream-socat-tls + fail-now "TLS Proxying failed" + fi +} + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/upstream-agent conf/downstream-agent + +# Test at most the last five minor releases. +MAX_ENVOY_RELEASES_TO_TEST=5 + +# Don't test earlier than v1.13, when was the first release to include the v3 +# API. +EARLIEST_ENVOY_RELEASE_TO_TEST=v1.13 + +envoy-releases + +log-info "Releases to test: ${ENVOY_RELEASES_TO_TEST[@]}" + +# Do some preliminary setup +setup-tests + +# Execute the tests for each release under test. The spire-server should remain +# up across these tests to minimize teardown/setup costs that are tangential +# to the support (since we're only testing the SDS integration). +for release in "${ENVOY_RELEASES_TO_TEST[@]}"; do + log-info "Building Envoy ${release}..." + build-mashup-image "${release}" + + log-info "Testing Envoy ${release}..." + + docker-up + + test-envoy + + # stop and clear everything but the server container + docker compose stop \ + upstream-proxy \ + downstream-proxy \ + upstream-socat \ + downstream-socat-mtls \ + downstream-socat-tls + + docker compose rm -f +done diff --git a/test/integration/cassandra-suites/envoy-sds-v3/README.md b/test/integration/cassandra-suites/envoy-sds-v3/README.md new file mode 100644 index 0000000000..0b4e883a25 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3/README.md @@ -0,0 +1,13 @@ +# Envoy SDS v3 Suite + +## Description + +Exercises [Envoy](https://www.envoyproxy.io/) +[SDS](https://www.envoyproxy.io/docs/envoy/latest/configuration/security/secret) +compatibility within SPIRE by wiring up two workloads that achieve connectivity +using Envoy backed with identities and trust information retrieved from the +SPIRE agent SDS implementation. + +A customer container image is used that runs both Envoy and the SPIRE agent. Socat containers are used as the workload. + +The test ensures both TLS and mTLS connectivity between the workload. diff --git a/test/integration/cassandra-suites/envoy-sds-v3/conf/downstream-agent/agent.conf b/test/integration/cassandra-suites/envoy-sds-v3/conf/downstream-agent/agent.conf new file mode 100644 index 0000000000..31d10cf4d0 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3/conf/downstream-agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + socket_path ="/opt/shared/agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/envoy-sds-v3/conf/downstream-envoy/envoy.yaml b/test/integration/cassandra-suites/envoy-sds-v3/conf/downstream-envoy/envoy.yaml new file mode 100644 index 0000000000..551496729c --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3/conf/downstream-envoy/envoy.yaml @@ -0,0 +1,125 @@ +node: + id: "downstream-envoy" + cluster: "test" +static_resources: + listeners: + - name: downstream_to_upstream_mtls_listener + address: + socket_address: + address: 0.0.0.0 + port_value: 8001 + filter_chains: + - filters: + - name: envoy.tcp_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: downstream_to_upstream_mtls + stat_prefix: downstream_to_upstream_mtls + - name: downstream_to_upstream_tls_listener + address: + socket_address: + address: 0.0.0.0 + port_value: 8002 + filter_chains: + - filters: + - name: envoy.tcp_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: downstream_to_upstream_tls + stat_prefix: downstream_to_upstream_tls + clusters: + - name: spire_agent + connect_timeout: 0.25s + http2_protocol_options: {} + load_assignment: + cluster_name: spire_agent + endpoints: + - lb_endpoints: + - endpoint: + address: + pipe: + path: /opt/shared/agent.sock + - name: downstream_to_upstream_mtls + connect_timeout: 0.25s + type: strict_dns + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: downstream_to_upstream_mtls + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: upstream-proxy + port_value: 8001 + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificate_sds_secret_configs: + - name: "spiffe://domain.test/downstream-workload" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + combined_validation_context: + default_validation_context: + match_typed_subject_alt_names: + - san_type: URI + matcher: + exact: "spiffe://domain.test/upstream-workload" + validation_context_sds_secret_config: + name: "spiffe://domain.test" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + tls_params: + ecdh_curves: + - X25519:P-256:P-521:P-384 + - name: downstream_to_upstream_tls + connect_timeout: 0.25s + type: strict_dns + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: downstream_to_upstream_tls + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: upstream-proxy + port_value: 8002 + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + combined_validation_context: + default_validation_context: + match_typed_subject_alt_names: + - san_type: URI + matcher: + exact: "spiffe://domain.test/upstream-workload" + validation_context_sds_secret_config: + name: "spiffe://domain.test" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + tls_params: + ecdh_curves: + - X25519:P-256:P-521:P-384 diff --git a/test/integration/cassandra-suites/envoy-sds-v3/conf/server/server.conf b/test/integration/cassandra-suites/envoy-sds-v3/conf/server/server.conf new file mode 100644 index 0000000000..b00be86ec9 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3/conf/server/server.conf @@ -0,0 +1,48 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "1m" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/envoy-sds-v3/conf/supervisord.conf b/test/integration/cassandra-suites/envoy-sds-v3/conf/supervisord.conf new file mode 100644 index 0000000000..516b053686 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3/conf/supervisord.conf @@ -0,0 +1,9 @@ +[supervisord] +nodaemon=true +loglevel=debug + +[program:spire-agent] +command = /opt/spire/bin/spire-agent run -config /opt/spire/conf/agent/agent.conf + +[program:envoy] +command = /usr/local/bin/envoy -l debug -c /opt/envoy/conf/envoy.yaml diff --git a/test/integration/cassandra-suites/envoy-sds-v3/conf/upstream-agent/agent.conf b/test/integration/cassandra-suites/envoy-sds-v3/conf/upstream-agent/agent.conf new file mode 100644 index 0000000000..31d10cf4d0 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3/conf/upstream-agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + socket_path ="/opt/shared/agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/envoy-sds-v3/conf/upstream-envoy/envoy.yaml b/test/integration/cassandra-suites/envoy-sds-v3/conf/upstream-envoy/envoy.yaml new file mode 100644 index 0000000000..79fe087384 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3/conf/upstream-envoy/envoy.yaml @@ -0,0 +1,107 @@ +node: + id: "upstream-envoy" + cluster: "test" +static_resources: + listeners: + - name: listener-sds-mtls + address: + socket_address: + address: 0.0.0.0 + port_value: 8001 + filter_chains: + - filters: + - name: envoy.tcp_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: upstream_socat + stat_prefix: upstream_socat_mtls + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext + common_tls_context: + tls_certificate_sds_secret_configs: + - name: "spiffe://domain.test/upstream-workload" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + combined_validation_context: + default_validation_context: + match_typed_subject_alt_names: + - san_type: URI + matcher: + exact: "spiffe://domain.test/downstream-workload" + validation_context_sds_secret_config: + name: "spiffe://domain.test" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + tls_params: + ecdh_curves: + - X25519:P-256:P-521:P-384 + - name: listener-sds-tls + address: + socket_address: + address: 0.0.0.0 + port_value: 8002 + filter_chains: + - filters: + - name: envoy.tcp_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: upstream_socat + stat_prefix: upstream_socat_tls + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext + common_tls_context: + tls_certificate_sds_secret_configs: + - name: "spiffe://domain.test/upstream-workload" + sds_config: + resource_api_version: V3 + api_config_source: + api_type: GRPC + transport_api_version: V3 + grpc_services: + envoy_grpc: + cluster_name: spire_agent + tls_params: + ecdh_curves: + - X25519:P-256:P-521:P-384 + + clusters: + - name: spire_agent + connect_timeout: 0.25s + http2_protocol_options: {} + load_assignment: + cluster_name: spire_agent + endpoints: + - lb_endpoints: + - endpoint: + address: + pipe: + path: /opt/shared/agent.sock + - name: upstream_socat + connect_timeout: 0.25s + type: strict_dns + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: upstream_socat + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: upstream-socat + port_value: 8000 diff --git a/test/integration/cassandra-suites/envoy-sds-v3/docker-compose.yaml b/test/integration/cassandra-suites/envoy-sds-v3/docker-compose.yaml new file mode 100644 index 0000000000..7b9ab6d8d7 --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3/docker-compose.yaml @@ -0,0 +1,53 @@ +services: + spire-server: + image: spire-server:latest-local + hostname: spire-server + env_file: + - ../.env + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + upstream-proxy: + image: envoy-agent-mashup + hostname: upstream-proxy + env_file: + - ../.env + depends_on: ["spire-server", "upstream-socat"] + volumes: + - ./conf/upstream-envoy:/opt/envoy/conf + - ./conf/upstream-agent:/opt/spire/conf/agent + downstream-proxy: + image: envoy-agent-mashup + env_file: + - ../.env + hostname: downstream-proxy + depends_on: ["spire-server", "upstream-proxy"] + volumes: + - ./conf/downstream-agent:/opt/spire/conf/agent + - ./conf/downstream-envoy:/opt/envoy/conf + upstream-socat: + image: alpine/socat:latest + env_file: + - ../.env + hostname: upstream-socat + command: ["-d", "-d", "TCP-LISTEN:8000,fork", "OPEN:\"/tmp/howdy\",creat,append"] + downstream-socat-mtls: + image: alpine/socat:latest + env_file: + - ../.env + hostname: downstream-socat-mtls + restart: on-failure + depends_on: ["downstream-proxy"] + tty: true + command: ["-d", "-d", "TCP-LISTEN:8001,fork", "TCP:downstream-proxy:8001"] + downstream-socat-tls: + image: alpine/socat:latest + env_file: + - ../.env + hostname: downstream-socat-tls + restart: on-failure + depends_on: ["downstream-proxy"] + tty: true + command: ["-d", "-d", "TCP-LISTEN:8002,fork", "TCP:downstream-proxy:8002"] diff --git a/test/integration/cassandra-suites/envoy-sds-v3/teardown b/test/integration/cassandra-suites/envoy-sds-v3/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/envoy-sds-v3/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/events-based-entries b/test/integration/cassandra-suites/events-based-entries new file mode 120000 index 0000000000..cb3f57e937 --- /dev/null +++ b/test/integration/cassandra-suites/events-based-entries @@ -0,0 +1 @@ +entries \ No newline at end of file diff --git a/test/integration/cassandra-suites/evict-agent/00-setup b/test/integration/cassandra-suites/evict-agent/00-setup new file mode 100755 index 0000000000..49c69db23c --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/00-setup @@ -0,0 +1,3 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent diff --git a/test/integration/cassandra-suites/evict-agent/01-start-server b/test/integration/cassandra-suites/evict-agent/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/evict-agent/02-bootstrap-agent b/test/integration/cassandra-suites/evict-agent/02-bootstrap-agent new file mode 100755 index 0000000000..4b33d1418c --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/02-bootstrap-agent @@ -0,0 +1,15 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." + +MAXCHECKS=30 +CHECKINTERVAL=1 +for ((i=1;i<=MAXCHECKS;i++)); do + log-info "trying to bootstrap agent ($i of $MAXCHECKS max)..." + docker compose logs spire-agent + if docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt; then + exit 0 + fi + sleep "${CHECKINTERVAL}" +done diff --git a/test/integration/cassandra-suites/evict-agent/03-start-agent b/test/integration/cassandra-suites/evict-agent/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/evict-agent/04-ban-agent b/test/integration/cassandra-suites/evict-agent/04-ban-agent new file mode 100755 index 0000000000..b9a7e96192 --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/04-ban-agent @@ -0,0 +1,24 @@ +#!/bin/bash + +log-debug "banning agent..." + +# Attempt at most 30 times (with one second in between) to ban the agent +MAXCHECKS=30 +CHECKINTERVAL=1 +spiffe_id="spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" +for ((i=1;i<=MAXCHECKS;i++)); do + log-info "attempting to ban agent ${spiffe_id} ($i of $MAXCHECKS max)..." + + # It is possible that the agent is not yet registered, so we need to retry + if docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent ban \ + -spiffeID "${spiffe_id}"; then + docker compose logs spire-server + if docker compose logs spire-server | grep "Agent banned"; then + exit 0 + fi + fi + sleep "${CHECKINTERVAL}" +done + +fail-now "timed out waiting for successful ban" diff --git a/test/integration/cassandra-suites/evict-agent/05-agent-is-banned b/test/integration/cassandra-suites/evict-agent/05-agent-is-banned new file mode 100755 index 0000000000..da9a22e828 --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/05-agent-is-banned @@ -0,0 +1,16 @@ +#!/bin/bash + +# Check at most 30 times (with one second in between) that the agent has +# been successfully banned +MAXCHECKS=30 +CHECKINTERVAL=1 +for ((i=1;i<=MAXCHECKS;i++)); do + log-info "checking for agent is shutting down due to being banned ($i of $MAXCHECKS max)..." + docker compose logs spire-agent + if docker compose logs spire-agent | grep "Agent is banned: removing SVID and shutting down"; then + exit 0 + fi + sleep "${CHECKINTERVAL}" +done + +fail-now "timed out waiting for agent to shutdown" diff --git a/test/integration/cassandra-suites/evict-agent/06-agent-failed-to-start b/test/integration/cassandra-suites/evict-agent/06-agent-failed-to-start new file mode 100755 index 0000000000..9a4132c7e2 --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/06-agent-failed-to-start @@ -0,0 +1,19 @@ +#!/bin/bash + +log-debug "starting agent again..." +docker-up spire-agent + +# Check at most 30 times (with one second in between) that the agent is not able to get new +# workload entries. +MAXCHECKS=30 +CHECKINTERVAL=1 +for ((i=1;i<=MAXCHECKS;i++)); do + log-info "checking that the agent is not able to start ($i of $MAXCHECKS max)..." + docker compose logs spire-agent + if docker compose logs spire-agent | grep "failed to fetch authorized entries:"; then + exit 0 + fi + sleep "${CHECKINTERVAL}" +done + +fail-now "agent started" diff --git a/test/integration/cassandra-suites/evict-agent/07-evict-agent b/test/integration/cassandra-suites/evict-agent/07-evict-agent new file mode 100755 index 0000000000..8d63703f95 --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/07-evict-agent @@ -0,0 +1,20 @@ +#!/bin/bash + +log-debug "evicting (deleting) agent to re-enable attestation..." + +# Check at most 30 times (with one second in between) that we can evict the agent +MAXCHECKS=30 +CHECKINTERVAL=1 +spiffe_id="spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" +for ((i=1;i<=MAXCHECKS;i++)); do + log-info "attempting to evict agent ${spiffe_id} ($i of $MAXCHECKS max)..." + + docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent evict \ + -spiffeID ${spiffe_id} + docker compose logs spire-server + if docker compose logs spire-server | grep "Agent deleted"; then + exit 0 + fi + sleep "${CHECKINTERVAL}" +done diff --git a/test/integration/cassandra-suites/evict-agent/08-agent-reattest-attempt b/test/integration/cassandra-suites/evict-agent/08-agent-reattest-attempt new file mode 100755 index 0000000000..1ecb0dce9d --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/08-agent-reattest-attempt @@ -0,0 +1,20 @@ +#!/bin/bash + +log-debug "agent re-attesting..." + +# Check at most 30 times (with one second in between) that the agent knows it can re-attest. +# This is not true "re-attestation" since when the agent was banned it removed its own SVID. +MAXCHECKS=30 +CHECKINTERVAL=1 +for ((i=1;i<=MAXCHECKS;i++)); do + log-info "checking for agent to get notification and try to reattest ($i of $MAXCHECKS max)..." + log-debug "starting agent again..." + docker-up spire-agent + docker compose logs spire-agent + if docker compose logs spire-agent | grep "SVID is not found. Starting node attestation"; then + exit 0 + fi + sleep "${CHECKINTERVAL}" +done + +fail-now "timed out waiting for agent to try to re-attest" diff --git a/test/integration/cassandra-suites/evict-agent/09-agent-reattested b/test/integration/cassandra-suites/evict-agent/09-agent-reattested new file mode 100755 index 0000000000..ed086920f0 --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/09-agent-reattested @@ -0,0 +1,15 @@ +#!/bin/bash + +# Check at most 30 times (with one second in between) that the agent has re-attested +MAXCHECKS=30 +CHECKINTERVAL=1 +for ((i=1;i<=MAXCHECKS;i++)); do + log-info "checking for agent to get notification that it re-attested ($i of $MAXCHECKS max)..." + docker compose logs spire-agent + if docker compose logs spire-agent | grep "Node attestation was successful"; then + exit 0 + fi + sleep "${CHECKINTERVAL}" +done + +fail-now "timed out waiting for agent to re-attest" diff --git a/test/integration/cassandra-suites/evict-agent/10-start-agent b/test/integration/cassandra-suites/evict-agent/10-start-agent new file mode 100755 index 0000000000..1597a12e14 --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/10-start-agent @@ -0,0 +1,19 @@ +#!/bin/bash + +log-debug "starting agent again..." +log-debug "bringing agent down..." +docker-down spire-agent +log-debug "starting agent again..." +docker-up spire-agent + +# Check at most 30 times (with one second in between) that the agent is back up +MAXCHECKS=30 +CHECKINTERVAL=1 +for ((i=1;i<=MAXCHECKS;i++)); do + log-info "checking that the agent is back up ($i of $MAXCHECKS max)..." + docker compose logs spire-agent + if docker compose logs spire-agent | grep "Starting Workload and SDS APIs"; then + exit 0 + fi + sleep "${CHECKINTERVAL}" +done diff --git a/test/integration/cassandra-suites/evict-agent/README.md b/test/integration/cassandra-suites/evict-agent/README.md new file mode 100644 index 0000000000..b2659e01ff --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/README.md @@ -0,0 +1,6 @@ +# Ban and Evict Suite + +## Description + +This suite validates than banned agent is no longer able to fetch updates from SPIRE Server, +and once agent entry is evicted agent is shutdown. diff --git a/test/integration/cassandra-suites/evict-agent/conf/agent/agent.conf b/test/integration/cassandra-suites/evict-agent/conf/agent/agent.conf new file mode 100644 index 0000000000..f79c4e9b06 --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/conf/agent/agent.conf @@ -0,0 +1,26 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/evict-agent/conf/server/server.conf b/test/integration/cassandra-suites/evict-agent/conf/server/server.conf new file mode 100644 index 0000000000..64d6dbd78b --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/conf/server/server.conf @@ -0,0 +1,48 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "20m" + default_x509_svid_ttl = "10m" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/evict-agent/docker-compose.yaml b/test/integration/cassandra-suites/evict-agent/docker-compose.yaml new file mode 100644 index 0000000000..fa470cc511 --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + spire-server: + image: spire-server:latest-local + hostname: spire-server + env_file: + - ../.env + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + hostname: spire-agent + env_file: + - ../.env + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/evict-agent/teardown b/test/integration/cassandra-suites/evict-agent/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/evict-agent/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/00-setup b/test/integration/cassandra-suites/fetch-jwt-svids/00-setup new file mode 100755 index 0000000000..c1fb18218e --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/00-setup @@ -0,0 +1,6 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent + +"${ROOTDIR}/setup/debugserver/build.sh" "${RUNDIR}/conf/server/debugclient" +"${ROOTDIR}/setup/debugagent/build.sh" "${RUNDIR}/conf/agent/debugclient" diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/01-start-server b/test/integration/cassandra-suites/fetch-jwt-svids/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/02-bootstrap-agent b/test/integration/cassandra-suites/fetch-jwt-svids/02-bootstrap-agent new file mode 100755 index 0000000000..8ee7d32c26 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/02-bootstrap-agent @@ -0,0 +1,5 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/03-start-agent b/test/integration/cassandra-suites/fetch-jwt-svids/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/04-create-registration-entries b/test/integration/cassandra-suites/fetch-jwt-svids/04-create-registration-entries new file mode 100755 index 0000000000..04cfbd9a07 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/04-create-registration-entries @@ -0,0 +1,20 @@ +#!/bin/bash + +# LRU Cache size is 8; we expect uid:1001 to receive all 10 identities, +# and later on disconnect for the cache to be pruned back to 8 +SIZE=10 + +# Create entries for uid 1001 +for ((m=1;m<=$SIZE;m++)); do + log-debug "creating registration entry: $m" + docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload-$m" \ + -selector "unix:uid:1001" \ + -jwtSVIDTTL 0 & +done + +for ((m=1;m<=$SIZE;m++)); do + check-synced-entry "spire-agent" "spiffe://domain.test/workload-$m" +done diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/05-fetch-jwt-svids b/test/integration/cassandra-suites/fetch-jwt-svids/05-fetch-jwt-svids new file mode 100755 index 0000000000..aebd741a1d --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/05-fetch-jwt-svids @@ -0,0 +1,17 @@ +#!/bin/bash + +ENTRYCOUNT=10 +CACHESIZE=8 + +JWTSVIDCOUNT=$(docker compose exec -u 1001 -T spire-agent \ + /opt/spire/bin/spire-agent api fetch jwt -audience test \ + -socketPath /opt/spire/sockets/workload_api.sock | grep -i "spiffe://domain.test/workload-" | wc -l || fail-now "JWT-SVID check failed") + +if [ "$JWTSVIDCOUNT" -ne "$ENTRYCOUNT" ]; then + fail-now "JWT-SVID check failed. Expected $ENTRYCOUNT JWT-SVIDs but received $JWTSVIDCOUNT for uid 1001"; +else + log-info "Expected $ENTRYCOUNT JWT-SVIDs and received $JWTSVIDCOUNT for uid 1001"; +fi + +# Call agent debug endpoints and check if extra JWT-SVIDs from cache are cleaned up +check-svid-count "spire-agent" $CACHESIZE diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/06-create-registration-entries b/test/integration/cassandra-suites/fetch-jwt-svids/06-create-registration-entries new file mode 100755 index 0000000000..bf6f3a4482 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/06-create-registration-entries @@ -0,0 +1,20 @@ +#!/bin/bash + +# LRU Cache size is 8; we expect uid:1002 to receive all 10 identities, +# and later on disconnect for the cache to be pruned back to 8 +SIZE=10 + +# Create entries for uid 1002 +for ((m=1;m<=$SIZE;m++)); do + log-debug "creating registration entry...($m)" + docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload/$m" \ + -selector "unix:uid:1002" \ + -jwtSVIDTTL 0 & +done + +for ((m=1;m<=$SIZE;m++)); do + check-synced-entry "spire-agent" "spiffe://domain.test/workload/$m" +done diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/07-fetch-jwt-svids b/test/integration/cassandra-suites/fetch-jwt-svids/07-fetch-jwt-svids new file mode 100755 index 0000000000..a2f835040c --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/07-fetch-jwt-svids @@ -0,0 +1,27 @@ +#!/bin/bash + +ENTRYCOUNT=10 +CACHESIZE=8 + +JWTSVIDCOUNT=$(docker compose exec -u 1002 -T spire-agent \ + /opt/spire/bin/spire-agent api fetch jwt -audience test \ + -socketPath /opt/spire/sockets/workload_api.sock | grep -i "spiffe://domain.test/workload/" | wc -l || fail-now "JWT-SVID check failed") + +if [ "$JWTSVIDCOUNT" -ne "$ENTRYCOUNT" ]; then + fail-now "JWT-SVID check failed. Expected $ENTRYCOUNT JWT-SVIDs but received $JWTSVIDCOUNT for uid 1002"; +else + log-info "Expected $ENTRYCOUNT JWT-SVIDs and received $JWTSVIDCOUNT for uid 1002"; +fi + +JWTSVIDCOUNT=$(docker compose exec -u 1001 -T spire-agent \ + /opt/spire/bin/spire-agent api fetch jwt -audience test \ + -socketPath /opt/spire/sockets/workload_api.sock | grep -i "spiffe://domain.test/workload-" | wc -l || fail-now "JWT-SVID check failed") + +if [ "$JWTSVIDCOUNT" -ne "$ENTRYCOUNT" ]; then + fail-now "JWT-SVID check failed. Expected $ENTRYCOUNT JWT-SVIDs but received $JWTSVIDCOUNT for uid 1001"; +else + log-info "Expected $ENTRYCOUNT JWT-SVIDs and received $JWTSVIDCOUNT for uid 1001"; +fi + +# Call agent debug endpoints and check if extra JWT-SVIDs from cache are cleaned up +check-svid-count "spire-agent" $CACHESIZE diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/08-test-disabled-jwt b/test/integration/cassandra-suites/fetch-jwt-svids/08-test-disabled-jwt new file mode 100755 index 0000000000..c69353b0bb --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/08-test-disabled-jwt @@ -0,0 +1,45 @@ +#!/bin/bash + +log-info "testing disabled JWT functionality..." + +# Replace with disable_jwt_svids = true configuration +sed -i.bak "s#disable_jwt_svids = false#disable_jwt_svids = true#g" conf/server/server.conf +docker compose restart spire-server +check-server-started spire-server + +test-jwt-disabled-command() { + local test_name="$1" + shift 1 + local command=("$@") + + if ! ERROR_OUTPUT=$("${command[@]}" 2>&1); then + if echo "$ERROR_OUTPUT" | grep -q "JWT functionality is disabled"; then + log-info "✓ $test_name correctly failed with JWT disabled error" + else + fail-now "$test_name expected JWT disabled error, but got: $ERROR_OUTPUT" + fi + else + fail-now "$test_name should have failed when JWT is disabled" + fi +} + +test-jwt-disabled-command "JWT authority prepare" \ + docker compose exec -T spire-server /opt/spire/bin/spire-server localauthority jwt prepare + +test-jwt-disabled-command "JWT authority show" \ + docker compose exec -T spire-server /opt/spire/bin/spire-server localauthority jwt show + +test-jwt-disabled-command "JWT authority activate" \ + docker compose exec -t spire-server /opt/spire/bin/spire-server localauthority jwt activate -authorityID test + +test-jwt-disabled-command "JWT authority revoke" \ + docker compose exec -t spire-server /opt/spire/bin/spire-server localauthority jwt revoke -authorityID test + +test-jwt-disabled-command "JWT authority taint" \ + docker compose exec -t spire-server /opt/spire/bin/spire-server localauthority jwt taint -authorityID test + +test-jwt-disabled-command "JWT mint" \ + docker compose exec -t spire-server /opt/spire/bin/spire-server jwt mint -spiffeID spiffe://domain.test/workload/10 -audience test + +test-jwt-disabled-command "JWT fetch" \ + docker compose exec -u 1001 -T spire-agent /opt/spire/bin/spire-agent api fetch jwt -audience test -socketPath /opt/spire/sockets/workload_api.sock diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/README.md b/test/integration/cassandra-suites/fetch-jwt-svids/README.md new file mode 100644 index 0000000000..33c246570c --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/README.md @@ -0,0 +1,8 @@ +# Fetch JWT-SVID Suite + +## Description + +This suite validates: + +- JWT-SVID cache operations +- Disablement of JWT-SVIDs via config flag diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/conf/agent/agent.conf b/test/integration/cassandra-suites/fetch-jwt-svids/conf/agent/agent.conf new file mode 100644 index 0000000000..6892317d03 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/conf/agent/agent.conf @@ -0,0 +1,30 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" + admin_socket_path = "/opt/debug.sock" + x509_svid_cache_max_size = 8 + jwt_svid_cache_max_size = 8 +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/conf/server/server.conf b/test/integration/cassandra-suites/fetch-jwt-svids/conf/server/server.conf new file mode 100644 index 0000000000..e2feaa7c9e --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/conf/server/server.conf @@ -0,0 +1,51 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + default_jwt_svid_ttl = "10m" + disable_jwt_svids = false + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "ERROR" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "disk" { + plugin_data = { + keys_path = "/opt/spire/data/server/keys.json" + } + } +} \ No newline at end of file diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/docker-compose.yaml b/test/integration/cassandra-suites/fetch-jwt-svids/docker-compose.yaml new file mode 100644 index 0000000000..808ddfb610 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + spire-server: + image: spire-server:latest-local + hostname: spire-server + env_file: + - ../.env + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/fetch-jwt-svids/teardown b/test/integration/cassandra-suites/fetch-jwt-svids/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/fetch-jwt-svids/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/fetch-wit-svids/00-setup b/test/integration/cassandra-suites/fetch-wit-svids/00-setup new file mode 100755 index 0000000000..c1fb18218e --- /dev/null +++ b/test/integration/cassandra-suites/fetch-wit-svids/00-setup @@ -0,0 +1,6 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent + +"${ROOTDIR}/setup/debugserver/build.sh" "${RUNDIR}/conf/server/debugclient" +"${ROOTDIR}/setup/debugagent/build.sh" "${RUNDIR}/conf/agent/debugclient" diff --git a/test/integration/cassandra-suites/fetch-wit-svids/01-start-server b/test/integration/cassandra-suites/fetch-wit-svids/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-wit-svids/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/fetch-wit-svids/02-check-bundle b/test/integration/cassandra-suites/fetch-wit-svids/02-check-bundle new file mode 100755 index 0000000000..ff247d47be --- /dev/null +++ b/test/integration/cassandra-suites/fetch-wit-svids/02-check-bundle @@ -0,0 +1,4 @@ +#!/bin/bash + +# Verify that there are no WIT-SVIDs by default +docker compose exec -T spire-server /opt/spire/bin/spire-server bundle show -output json | jq -e '.wit_authorities | length != 0' || fail-now "wit_authorities should have been non-empty" diff --git a/test/integration/cassandra-suites/fetch-wit-svids/README.md b/test/integration/cassandra-suites/fetch-wit-svids/README.md new file mode 100644 index 0000000000..130babf452 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-wit-svids/README.md @@ -0,0 +1,7 @@ +# Fetch WIT-SVID Suite + +## Description + +This suite validates: + +- Bundle contains WIT authorities only if WIT profile is enabled. diff --git a/test/integration/cassandra-suites/fetch-wit-svids/conf/server/server.conf b/test/integration/cassandra-suites/fetch-wit-svids/conf/server/server.conf new file mode 100644 index 0000000000..e353ab2b9e --- /dev/null +++ b/test/integration/cassandra-suites/fetch-wit-svids/conf/server/server.conf @@ -0,0 +1,52 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + default_jwt_svid_ttl = "10m" + + experimental { + feature_flags = ["wit-svid"] + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "disk" { + plugin_data = { + keys_path = "/opt/spire/data/server/keys.json" + } + } +} diff --git a/test/integration/cassandra-suites/fetch-wit-svids/docker-compose.yaml b/test/integration/cassandra-suites/fetch-wit-svids/docker-compose.yaml new file mode 100644 index 0000000000..fb555293a4 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-wit-svids/docker-compose.yaml @@ -0,0 +1,11 @@ +services: + spire-server: + image: spire-server:latest-local + hostname: spire-server + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + env_file: + - ../.env + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/test/integration/cassandra-suites/fetch-wit-svids/teardown b/test/integration/cassandra-suites/fetch-wit-svids/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/fetch-wit-svids/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/fetch-x509-svids/00-setup b/test/integration/cassandra-suites/fetch-x509-svids/00-setup new file mode 100755 index 0000000000..c1fb18218e --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/00-setup @@ -0,0 +1,6 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent + +"${ROOTDIR}/setup/debugserver/build.sh" "${RUNDIR}/conf/server/debugclient" +"${ROOTDIR}/setup/debugagent/build.sh" "${RUNDIR}/conf/agent/debugclient" diff --git a/test/integration/cassandra-suites/fetch-x509-svids/01-start-server b/test/integration/cassandra-suites/fetch-x509-svids/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/fetch-x509-svids/02-bootstrap-agent b/test/integration/cassandra-suites/fetch-x509-svids/02-bootstrap-agent new file mode 100755 index 0000000000..8ee7d32c26 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/02-bootstrap-agent @@ -0,0 +1,5 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt diff --git a/test/integration/cassandra-suites/fetch-x509-svids/03-start-agent b/test/integration/cassandra-suites/fetch-x509-svids/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/fetch-x509-svids/04-create-registration-entries b/test/integration/cassandra-suites/fetch-x509-svids/04-create-registration-entries new file mode 100755 index 0000000000..356c7ad908 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/04-create-registration-entries @@ -0,0 +1,20 @@ +#!/bin/bash + +# LRU Cache size is 8; we expect uid:1001 to receive all 10 identities, +# and later on disconnect for the cache to be pruned back to 8 +SIZE=10 + +# Create entries for uid 1001 +for ((m=1;m<=$SIZE;m++)); do + log-debug "creating registration entry: $m" + docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload-$m" \ + -selector "unix:uid:1001" \ + -x509SVIDTTL 0 & +done + +for ((m=1;m<=$SIZE;m++)); do + check-synced-entry "spire-agent" "spiffe://domain.test/workload-$m" +done diff --git a/test/integration/cassandra-suites/fetch-x509-svids/05-fetch-x509-svids b/test/integration/cassandra-suites/fetch-x509-svids/05-fetch-x509-svids new file mode 100755 index 0000000000..127ebe58d3 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/05-fetch-x509-svids @@ -0,0 +1,17 @@ +#!/bin/bash + +ENTRYCOUNT=10 +CACHESIZE=8 + +X509SVIDCOUNT=$(docker compose exec -u 1001 -T spire-agent \ + /opt/spire/bin/spire-agent api fetch x509 \ + -socketPath /opt/spire/sockets/workload_api.sock | grep -i "spiffe://domain.test" | wc -l || fail-now "X.509-SVID check failed") + +if [ "$X509SVIDCOUNT" -ne "$ENTRYCOUNT" ]; then + fail-now "X.509-SVID check failed. Expected $ENTRYCOUNT X.509-SVIDs but received $X509SVIDCOUNT for uid 1001"; +else + log-info "Expected $ENTRYCOUNT X.509-SVIDs and received $X509SVIDCOUNT for uid 1001"; +fi + +# Call agent debug endpoints and check if extra X.509-SVIDs from cache are cleaned up +check-svid-count "spire-agent" $CACHESIZE diff --git a/test/integration/cassandra-suites/fetch-x509-svids/06-create-registration-entries b/test/integration/cassandra-suites/fetch-x509-svids/06-create-registration-entries new file mode 100755 index 0000000000..9870a14691 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/06-create-registration-entries @@ -0,0 +1,20 @@ +#!/bin/bash + +# LRU Cache size is 8; we expect uid:1002 to receive all 10 identities, +# and later on disconnect for the cache to be pruned back to 8 +SIZE=10 + +# Create entries for uid 1002 +for ((m=1;m<=$SIZE;m++)); do + log-debug "creating registration entry...($m)" + docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload/$m" \ + -selector "unix:uid:1002" \ + -x509SVIDTTL 0 & +done + +for ((m=1;m<=$SIZE;m++)); do + check-synced-entry "spire-agent" "spiffe://domain.test/workload/$m" +done diff --git a/test/integration/cassandra-suites/fetch-x509-svids/07-fetch-x509-svids b/test/integration/cassandra-suites/fetch-x509-svids/07-fetch-x509-svids new file mode 100755 index 0000000000..f1b73e8efe --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/07-fetch-x509-svids @@ -0,0 +1,27 @@ +#!/bin/bash + +ENTRYCOUNT=10 +CACHESIZE=8 + +X509SVIDCOUNT=$(docker compose exec -u 1002 -T spire-agent \ + /opt/spire/bin/spire-agent api fetch x509 \ + -socketPath /opt/spire/sockets/workload_api.sock | grep -i "spiffe://domain.test" | wc -l || fail-now "X.509-SVID check failed") + +if [ "$X509SVIDCOUNT" -ne "$ENTRYCOUNT" ]; then + fail-now "X.509-SVID check failed. Expected $ENTRYCOUNT X.509-SVIDs but received $X509SVIDCOUNT for uid 1002"; +else + log-info "Expected $ENTRYCOUNT X.509-SVIDs and received $X509SVIDCOUNT for uid 1002"; +fi + +X509SVIDCOUNT=$(docker compose exec -u 1001 -T spire-agent \ + /opt/spire/bin/spire-agent api fetch x509 \ + -socketPath /opt/spire/sockets/workload_api.sock | grep -i "spiffe://domain.test" | wc -l || fail-now "X.509-SVID check failed") + +if [ "$X509SVIDCOUNT" -ne "$ENTRYCOUNT" ]; then + fail-now "X.509-SVID check failed. Expected $ENTRYCOUNT X.509-SVIDs but received $X509SVIDCOUNT for uid 1001"; +else + log-info "Expected $ENTRYCOUNT X.509-SVIDs and received $X509SVIDCOUNT for uid 1001"; +fi + +# Call agent debug endpoints and check if extra X.509-SVIDs from cache are cleaned up +check-svid-count "spire-agent" $CACHESIZE diff --git a/test/integration/cassandra-suites/fetch-x509-svids/README.md b/test/integration/cassandra-suites/fetch-x509-svids/README.md new file mode 100644 index 0000000000..896ed8deeb --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/README.md @@ -0,0 +1,5 @@ +# Fetch x509-SVID Suite + +## Description + +This suite validates X.509-SVID cache operations. diff --git a/test/integration/cassandra-suites/fetch-x509-svids/conf/agent/agent.conf b/test/integration/cassandra-suites/fetch-x509-svids/conf/agent/agent.conf new file mode 100644 index 0000000000..3beb6fc476 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/conf/agent/agent.conf @@ -0,0 +1,29 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" + admin_socket_path = "/opt/debug.sock" + x509_svid_cache_max_size = 8 +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/fetch-x509-svids/conf/server/server.conf b/test/integration/cassandra-suites/fetch-x509-svids/conf/server/server.conf new file mode 100644 index 0000000000..1efd99e92b --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/conf/server/server.conf @@ -0,0 +1,47 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/fetch-x509-svids/docker-compose.yaml b/test/integration/cassandra-suites/fetch-x509-svids/docker-compose.yaml new file mode 100644 index 0000000000..d8c2241661 --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + spire-server: + image: spire-server:latest-local + env_file: + - ../.env + hostname: spire-server + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/fetch-x509-svids/teardown b/test/integration/cassandra-suites/fetch-x509-svids/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/fetch-x509-svids/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/00-setup b/test/integration/cassandra-suites/force-rotation-jwt-authority/00-setup new file mode 100755 index 0000000000..7a467f829b --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/00-setup @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent + diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/01-start-server b/test/integration/cassandra-suites/force-rotation-jwt-authority/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/02-bootstrap-agent b/test/integration/cassandra-suites/force-rotation-jwt-authority/02-bootstrap-agent new file mode 100755 index 0000000000..8ee7d32c26 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/02-bootstrap-agent @@ -0,0 +1,5 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/03-start-agent b/test/integration/cassandra-suites/force-rotation-jwt-authority/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/04-create-workload-entry b/test/integration/cassandra-suites/force-rotation-jwt-authority/04-create-workload-entry new file mode 100755 index 0000000000..661c0ea6d8 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/04-create-workload-entry @@ -0,0 +1,14 @@ +#!/bin/bash + +log-debug "creating registration entry..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload" \ + -selector "unix:uid:0" \ + -x509SVIDTTL 0 +check-synced-entry "spire-agent" "spiffe://domain.test/workload" + +log-info "checking X509-SVID" +docker compose exec -T spire-agent \ + /opt/spire/bin/spire-agent api fetch x509 || fail-now "SVID check failed" diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/05-prepare-jwt-authority b/test/integration/cassandra-suites/force-rotation-jwt-authority/05-prepare-jwt-authority new file mode 100755 index 0000000000..0cbab9dc7d --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/05-prepare-jwt-authority @@ -0,0 +1,36 @@ +#!/bin/bash + +# Initial check for x509 authorities in spire-server +jwt_authorities=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show -output json | jq '.jwt_authorities' -c) + +amount_authorities=$(echo "$jwt_authorities" | jq length) + +# Ensure only one JWT authority is present at the start +if [[ $amount_authorities -ne 1 ]]; then + fail-now "Only one JWT authority expected at start" +fi + +# Prepare authority +prepared_authority_id=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server localauthority jwt prepare -output json | jq -r .prepared_authority.authority_id) + +# Verify that the prepared authority is logged +searching="JWT key prepared|local_authority_id=${prepared_authority_id}" +check-log-line spire-server "$searching" + +# Check for updated x509 authorities in spire-server +# Check for updated JWT authorities in spire-server +jwt_authorities=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show -output json | jq '.jwt_authorities' -c) +amount_authorities=$(echo "$jwt_authorities" | jq length) + +# Ensure two JWT authorities are present after preparation +if [[ $amount_authorities -ne 2 ]]; then + fail-now "Two JWT authorities expected after prepare" +fi + +# Ensure the prepared authority is present +if ! echo "$jwt_authorities" | jq -e ".[] | select(.key_id == \"$prepared_authority_id\")" > /dev/null; then + fail-now "Prepared authority not found" +fi diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/06-fetch-jwt-svid b/test/integration/cassandra-suites/force-rotation-jwt-authority/06-fetch-jwt-svid new file mode 100755 index 0000000000..45424203dc --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/06-fetch-jwt-svid @@ -0,0 +1,50 @@ +#!/bin/bash + +prepared_authority=$(docker compose exec -t spire-server \ + /opt/spire/bin/spire-server \ + localauthority jwt show -output json | jq -r .active.authority_id) || fail-now "Failed to fetch prepared JWT authority ID" + +svid_json=$(docker compose exec spire-agent ./bin/spire-agent \ + api fetch jwt -audience aud -output json) || fail-now "Failed to fetch JWT SVID" + +jwt_svid=$(echo $svid_json | jq -c '.[0].svids[0].svid') || fail-now "Failed to parse JWT SVID" + +# Store JWT SVID for the next steps +echo $jwt_svid > conf/agent/jwt_svid + +# Extract key ID from JWT SVID +skid=$(echo "$jwt_svid" | jq -r 'split(".") | .[0] | @base64d | fromjson | .kid') + +# Check if the key ID matches the prepared authority ID +if [[ $skid != $prepared_authority ]]; then + fail-now "JWT SVID key ID does not match the prepared authority ID, got $skid, expected $prepared_authority" +fi + +keys=$(echo $svid_json | jq -c '.[1].bundles["spiffe://domain.test"] | @base64d | fromjson') + +retry_count=0 +max_retries=20 +success=false + +while [[ $retry_count -lt $max_retries ]]; do + keysLen=$(echo $keys | jq -c '.keys | length') + if [[ $keysLen -eq 2 ]]; then + success=true + break + else + echo "Retrying... ($((retry_count+1))/$max_retries)" + retry_count=$((retry_count+1)) + sleep 2 + # Re-fetch the JWT SVID and keys + svid_json=$(docker compose exec spire-agent ./bin/spire-agent \ + api fetch jwt -audience aud -output json) || fail-now "Failed to re-fetch JWT SVID" + jwt_svid=$(echo $svid_json | jq -c '.[0].svids[0].svid') || fail-now "Failed to parse re-fetched JWT SVID" + keys=$(echo $svid_json | jq -c '.[1].bundles["spiffe://domain.test"] | @base64d | fromjson') + fi +done + +if [[ $success == false ]]; then + fail-now "Expected one key in JWT SVID bundle, got $keysLen after $max_retries retries" +fi + +echo $keys | jq --arg kid $prepared_authority -e '.keys[] | select(.kid == $kid)' > /dev/null || fail-now "Prepared authority not found in JWT SVID bundle" diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/07-activate-jwt-authority b/test/integration/cassandra-suites/force-rotation-jwt-authority/07-activate-jwt-authority new file mode 100755 index 0000000000..2a546fe94f --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/07-activate-jwt-authority @@ -0,0 +1,18 @@ +#!/bin/bash + +# Fetch the prepared authority ID +prepared_authority=$(docker compose exec -t spire-server \ + /opt/spire/bin/spire-server \ + localauthority jwt show -output json | jq -r .prepared.authority_id) || fail-now "Failed to fetch prepared JWT authority ID" + +# Activate the authority +activated_authority=$(docker compose exec -t spire-server \ + /opt/spire/bin/spire-server \ + localauthority jwt activate -authorityID "${prepared_authority}" \ + -output json | jq -r .activated_authority.authority_id) || fail-now "Failed to activate JWT authority" + +log-info "Activated authority: ${activated_authority}" + +# Check logs for specific lines +check-log-line spire-server "JWT key activated|local_authority_id=${prepared_authority}" + diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/08-taint-jwt-authority b/test/integration/cassandra-suites/force-rotation-jwt-authority/08-taint-jwt-authority new file mode 100755 index 0000000000..9ce538b113 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/08-taint-jwt-authority @@ -0,0 +1,30 @@ +#!/bin/bash + +check-logs() { + local component=$1 + shift + for log in "$@"; do + check-log-line "$component" "$log" + done +} + +# Fetch old authority ID +old_jwt_authority=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server \ + localauthority jwt show -output json | jq -r .old.authority_id) || fail-now "Failed to fetch old authority ID" + +log-debug "Old authority: $old_jwt_authority" + +# Taint the old authority +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server \ + localauthority jwt taint -authorityID "${old_jwt_authority}" || fail-now "Failed to taint old authority" + +# check Server logs +check-logs spire-server \ + "JWT authority tainted successfully|local_authority_id=${old_jwt_authority}" + +# Check Agent logs +check-logs spire-agent \ + "JWT-SVIDs were removed from the JWT cache because they were issued by a tainted authority|count_jwt_svids=1|jwt_authority_key_ids=${old_jwt_authority}" + diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/09-verify-svid-rotation b/test/integration/cassandra-suites/force-rotation-jwt-authority/09-verify-svid-rotation new file mode 100755 index 0000000000..182972b4b4 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/09-verify-svid-rotation @@ -0,0 +1,21 @@ +#!/bin/bash + +active_authority=$(docker compose exec -t spire-server \ + /opt/spire/bin/spire-server \ + localauthority jwt show -output json | jq -r .active.authority_id) || fail-now "Failed to fetch active JWT authority ID" + +jwt_svid=$(docker compose exec spire-agent ./bin/spire-agent \ + api fetch jwt -audience aud -output json | jq -c '.[0].svids[0].svid') || fail-now "Failed to fetch JWT SVID" + +oldJWT=$(cat conf/agent/jwt_svid) +if [[ $oldJWT == $jwt_svid ]]; then + fail-now "JWT SVID did not rotate" +fi + +# Extract key ID from JWT SVID +skid=$(echo "$jwt_svid" | jq -r 'split(".") | .[0] | @base64d | fromjson | .kid') + +# Check if the key ID matches the active authority ID +if [[ $skid != $active_authority ]]; then + fail-now "JWT SVID key ID does not match the active authority ID, got $skid, expected $active_authority" +fi diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/10-revoke-jwt-authority b/test/integration/cassandra-suites/force-rotation-jwt-authority/10-revoke-jwt-authority new file mode 100755 index 0000000000..bfbda00568 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/10-revoke-jwt-authority @@ -0,0 +1,30 @@ +#!/bin/bash + +old_jwt_authority=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server \ + localauthority jwt show -output json | jq -r .old.authority_id) || fail-now "Failed to fetch old authority ID" + +log-debug "Old authority: $old_jwt_authority" + +jwt_authorities_count=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle \ + show -output json | jq '.jwt_authorities | length') + +if [ $jwt_authorities_count -eq 2 ]; then + log-debug "Two JWT Authorities found" +else + fail-now "Expected to be two JWT Authorities. Found $jwt_authorities_count." +fi + +tainted_found=$(docker compose exec -T spire-server /opt/spire/bin/spire-server bundle show -output json | jq '.jwt_authorities[] | select(.tainted == true)') + +if [[ -z "$tainted_found" ]]; then + fail-now "Tainted JWT authority expected" +fi + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server localauthority jwt \ + revoke -authorityID $old_jwt_authority -output json || fail-now "Failed to revoke JWT authority" + +check-log-line spire-server "JWT authority revoked successfully|local_authority_id=$old_jwt_authority" + diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/11-verify-revoked-jwt-authority b/test/integration/cassandra-suites/force-rotation-jwt-authority/11-verify-revoked-jwt-authority new file mode 100755 index 0000000000..e9c0e5a0e8 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/11-verify-revoked-jwt-authority @@ -0,0 +1,28 @@ +#!/bin/bash + +for i in {1..20}; do + active_jwt_authority=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server \ + localauthority jwt show -output json | jq -r .active.authority_id) || fail-now "Failed to fetch old jwt authority ID" + + log-debug "Active old authority: $active_jwt_authority" + + svid_json=$(docker compose exec spire-agent ./bin/spire-agent \ + api fetch jwt -audience aud -output json) + + keys=$(echo $svid_json | jq -c '.[1].bundles["spiffe://domain.test"] | @base64d | fromjson') + + keysLen=$(echo $keys | jq -c '.keys | length') + if [[ $keysLen -eq 1 ]]; then + break + fi + + if [[ $i -eq 20 ]]; then + fail-now "Expected one key in JWT SVID bundle, got $keysLen after 20 attempts" + fi + + sleep 2s +done + +echo $keys | jq --arg kid $active_jwt_authority -e '.keys[] | select(.kid == $kid)' > /dev/null || fail-now "Active authority not found in JWT SVID bundle" + diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/README.md b/test/integration/cassandra-suites/force-rotation-jwt-authority/README.md new file mode 100644 index 0000000000..63448f8e72 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/README.md @@ -0,0 +1,12 @@ +# Force rotation with JWT Authority Test Suite + +## Description + +This test suite configures a single SPIRE Server and Agent to validate the forced rotation and revocation of JWT authorities. + +## Test steps + +1. **Prepare a new JWT authority**: Verify that a new JWT authority is successfully created. +2. **Activate the new JWT authority**: Ensure that the new JWT authority becomes the active authority. +3. **Taint the old JWT authority**: Confirm that the old JWT authority is marked as tainted, and verify that the taint instruction is propagated to the agent, triggering the deletion of any JWT-SVID signed by tainted authority. +4. **Revoke the tainted JWT authority**: Validate that the revocation instruction is propagated to the agent and that all the JWT-SVIDs have the revoked authority removed. diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/conf/agent/agent.conf b/test/integration/cassandra-suites/force-rotation-jwt-authority/conf/agent/agent.conf new file mode 100644 index 0000000000..f79c4e9b06 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/conf/agent/agent.conf @@ -0,0 +1,26 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/conf/server/server.conf b/test/integration/cassandra-suites/force-rotation-jwt-authority/conf/server/server.conf new file mode 100644 index 0000000000..ef06211c7e --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/conf/server/server.conf @@ -0,0 +1,48 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "24h" + default_jwt_svid_ttl = "8h" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/docker-compose.yaml b/test/integration/cassandra-suites/force-rotation-jwt-authority/docker-compose.yaml new file mode 100644 index 0000000000..d8c2241661 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + spire-server: + image: spire-server:latest-local + env_file: + - ../.env + hostname: spire-server + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/force-rotation-jwt-authority/teardown b/test/integration/cassandra-suites/force-rotation-jwt-authority/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-jwt-authority/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/00-setup b/test/integration/cassandra-suites/force-rotation-self-signed/00-setup new file mode 100755 index 0000000000..607b5446e3 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/00-setup @@ -0,0 +1,27 @@ +#!/bin/bash + +# create shared folder for root agent socket +mkdir -p -m 777 shared/rootSocket + +# create shared folder for intermediateA agent socket +mkdir -p -m 777 shared/intermediateASocket + +# create shared folder for intermediateB agent socket +mkdir -p -m 777 shared/intermediateBSocket + +# root certificates +"${ROOTDIR}/setup/x509pop/setup.sh" root/server root/agent + +# intermediateA certificates +"${ROOTDIR}/setup/x509pop/setup.sh" intermediateA/server intermediateA/agent + +# leafA certificates +"${ROOTDIR}/setup/x509pop/setup.sh" leafA/server leafA/agent + +# intermediateB certificates +"${ROOTDIR}/setup/x509pop/setup.sh" intermediateB/server intermediateB/agent + +# leafB certificates +"${ROOTDIR}/setup/x509pop/setup.sh" leafB/server leafB/agent + +docker build --target nested-agent-alpine -t nested-agent-alpine . diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/01-start-root b/test/integration/cassandra-suites/force-rotation-self-signed/01-start-root new file mode 100755 index 0000000000..4b4e9713cd --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/01-start-root @@ -0,0 +1,12 @@ +#!/bin/bash + +log-debug "Starting root-server..." +docker-up root-server +check-server-started "root-server" + +log-debug "bootstrapping root-agent..." +docker compose exec -T root-server \ + /opt/spire/bin/spire-server bundle show > root/agent/bootstrap.crt + +log-debug "Starting root-agent..." +docker-up root-agent diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/02-create-intermediate-downstream-entries b/test/integration/cassandra-suites/force-rotation-self-signed/02-create-intermediate-downstream-entries new file mode 100755 index 0000000000..9e4e444f41 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/02-create-intermediate-downstream-entries @@ -0,0 +1,19 @@ +#!/bin/bash + +log-debug "creating intermediateA downstream registration entry..." +docker compose exec -T root-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint root/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/intermediateA" \ + -selector "docker:label:org.integration.name:intermediateA" \ + -downstream +check-synced-entry "root-agent" "spiffe://domain.test/intermediateA" + +log-debug "creating intermediateB downstream registration entry..." +docker compose exec -T root-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint root/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/intermediateB" \ + -selector "docker:label:org.integration.name:intermediateB" \ + -downstream +check-synced-entry "root-agent" "spiffe://domain.test/intermediateB" diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/03-start-intermediateA b/test/integration/cassandra-suites/force-rotation-self-signed/03-start-intermediateA new file mode 100755 index 0000000000..deff493764 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/03-start-intermediateA @@ -0,0 +1,12 @@ +#!/bin/bash + +log-debug "Starting intermediateA-server.." +docker-up intermediateA-server +check-server-started "intermediateA-server" + +log-debug "bootstrapping intermediateA agent..." +docker compose exec -T intermediateA-server \ + /opt/spire/bin/spire-server bundle show > intermediateA/agent/bootstrap.crt + +log-debug "Starting intermediateA-agent..." +docker-up intermediateA-agent diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/04-create-leafA-downstream-entry b/test/integration/cassandra-suites/force-rotation-self-signed/04-create-leafA-downstream-entry new file mode 100755 index 0000000000..e9c891d135 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/04-create-leafA-downstream-entry @@ -0,0 +1,12 @@ +#!/bin/bash + +log-debug "creating leafA downstream registration entry..." +# Create downstream registation entry on intermediateA-server for `leafA-server` +docker compose exec -T intermediateA-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint intermediateA/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/leafA" \ + -selector "docker:label:org.integration.name:leafA" \ + -downstream + +check-synced-entry "intermediateA-agent" "spiffe://domain.test/leafA" diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/05-start-leafA b/test/integration/cassandra-suites/force-rotation-self-signed/05-start-leafA new file mode 100755 index 0000000000..838e87202d --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/05-start-leafA @@ -0,0 +1,12 @@ +#!/bin/bash + +log-debug "Starting leafA-server.." +docker-up leafA-server +check-server-started "leafA-server" + +log-debug "bootstrapping leafA agent..." +docker compose exec -T leafA-server \ + /opt/spire/bin/spire-server bundle show > leafA/agent/bootstrap.crt + +log-debug "Starting leafA-agent..." +docker-up leafA-agent diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/06-start-intermediateB b/test/integration/cassandra-suites/force-rotation-self-signed/06-start-intermediateB new file mode 100755 index 0000000000..ee85af6bd1 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/06-start-intermediateB @@ -0,0 +1,12 @@ +#!/bin/bash + +log-debug "Starting intermediateB-server.." +docker-up intermediateB-server +check-server-started "intermediateB-server" + +log-debug "bootstrapping intermediateB downstream agent..." +docker compose exec -T intermediateB-server \ + /opt/spire/bin/spire-server bundle show > intermediateB/agent/bootstrap.crt + +log-debug "Starting intermediateB-agent..." +docker-up intermediateB-agent diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/07-create-leafB-downstream-entry b/test/integration/cassandra-suites/force-rotation-self-signed/07-create-leafB-downstream-entry new file mode 100755 index 0000000000..84d26804e1 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/07-create-leafB-downstream-entry @@ -0,0 +1,12 @@ +#!/bin/bash + +log-debug "creating leafB downstream registration entry..." +# Create downstream registration entry on itermediateB for leafB-server +docker compose exec -T intermediateB-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint intermediateB/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/leafB" \ + -selector "docker:label:org.integration.name:leafB" \ + -downstream + +check-synced-entry "intermediateB-agent" "spiffe://domain.test/leafB" diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/08-start-leafB b/test/integration/cassandra-suites/force-rotation-self-signed/08-start-leafB new file mode 100755 index 0000000000..61c3326594 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/08-start-leafB @@ -0,0 +1,12 @@ +#!/bin/bash + +log-debug "Starting leafB-server.." +docker-up leafB-server +check-server-started "leafB-server" + +log-debug "bootstrapping leafB agent..." +docker compose exec -T leafB-server \ + /opt/spire/bin/spire-server bundle show > leafB/agent/bootstrap.crt + +log-debug "Starting leafB-agent..." +docker-up leafB-agent diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/09-create-workload-entries b/test/integration/cassandra-suites/force-rotation-self-signed/09-create-workload-entries new file mode 100755 index 0000000000..79d1ea804e --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/09-create-workload-entries @@ -0,0 +1,46 @@ +#!/bin/bash + +log-debug "creating rootA workload registration entry..." +docker compose exec -T root-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint root/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/root/workload" \ + -selector "unix:uid:1001" \ + -x509SVIDTTL 0 +check-synced-entry "root-agent" "spiffe://domain.test/root/workload" + +log-debug "creating intermediateA workload registration entry..." +docker compose exec -T intermediateA-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint intermediateA/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/intermediateA/workload" \ + -selector "unix:uid:1001" \ + -x509SVIDTTL 0 +check-synced-entry "intermediateA-agent" "spiffe://domain.test/intermediateA/workload" + +log-debug "creating leafA workload registration entry..." +docker compose exec -T leafA-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint leafA/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/leafA/workload" \ + -selector "unix:uid:1001" \ + -x509SVIDTTL 0 +check-synced-entry "leafA-agent" "spiffe://domain.test/leafA/workload" + +log-debug "creating intermediateB workload registration entry..." +docker compose exec -T intermediateB-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint intermediateB/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/intermediateB/workload" \ + -selector "unix:uid:1001" \ + -x509SVIDTTL 0 +check-synced-entry "intermediateB-agent" "spiffe://domain.test/intermediateB/workload" + +log-debug "creating leafB workload registration entry..." +docker compose exec -T leafB-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint leafB/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/leafB/workload" \ + -selector "unix:uid:1001" \ + -x509SVIDTTL 0 +check-synced-entry "leafB-agent" "spiffe://domain.test/leafB/workload" diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/10-prepare-authority b/test/integration/cassandra-suites/force-rotation-self-signed/10-prepare-authority new file mode 100755 index 0000000000..20f49b034a --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/10-prepare-authority @@ -0,0 +1,71 @@ +#!/bin/bash + +# Constants +MAXCHECKS=30 +RETRY_DELAY=1 + +# Function to check x509 authorities propagation +check-x509-authorities() { + local expected_bundle=$1 + local container_name=$2 + local retry_count=0 + + while [[ $retry_count -lt $MAXCHECKS ]]; do + log-info "Checking for x509 authorities propagation ($retry_count of $MAXCHECKS max)..." + + x509_authorities=$(docker compose exec -T ${container_name} \ + /opt/spire/bin/spire-server bundle show -output json | jq '.x509_authorities' -c) + + if diff <(echo "$expected_bundle") <(echo "$x509_authorities") &>/dev/null; then + break + else + retry_count=$((retry_count + 1)) + log-debug "x509 authorities not propagated on ${container_name}, retrying in $RETRY_DELAY seconds... ($retry_count/$MAXCHECKS)" + sleep "${RETRY_DELAY}" + fi + + # Fail if retries exceed the maximum + if [[ $retry_count -eq $MAXCHECKS ]]; then + fail-now "Expected bundle: $expected_bundle got: $x509_authorities" + fi + done +} + +# Initial check for x509 authorities in root-server +x509_authorities=$(docker compose exec -T root-server \ + /opt/spire/bin/spire-server bundle show -output json | jq '.x509_authorities' -c) + +amount_bundles=$(echo "$x509_authorities" | jq length) + +# Ensure only one bundle is present at the start +if [[ $amount_bundles -ne 1 ]]; then + fail-now "Only one bundle expected at start" +fi + +# Check x509 authorities propagation across all servers +for server in intermediateA-server intermediateB-server leafA-server leafB-server; do + check-x509-authorities "$x509_authorities" "$server" +done + +# Prepare authority +prepared_authority_id=$(docker compose exec -T root-server \ + /opt/spire/bin/spire-server localauthority x509 prepare -output json | jq -r .prepared_authority.authority_id) + +# Verify that the prepared authority is logged +searching="X509 CA prepared.|local_authority_id=${prepared_authority_id}" +check-log-line root-server "$searching" + +# Check for updated x509 authorities in root-server +x509_authorities=$(docker compose exec -T root-server \ + /opt/spire/bin/spire-server bundle show -output json | jq '.x509_authorities' -c) +amount_bundles=$(echo "$x509_authorities" | jq length) + +# Ensure two bundles are present after preparation +if [[ $amount_bundles -ne 2 ]]; then + fail-now "Two bundles expected after prepare" +fi + +# Check x509 authorities propagation across all servers again +for server in intermediateA-server intermediateB-server leafA-server leafB-server; do + check-x509-authorities "$x509_authorities" "$server" +done diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/11-activate-x509authority b/test/integration/cassandra-suites/force-rotation-self-signed/11-activate-x509authority new file mode 100755 index 0000000000..7c9b086144 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/11-activate-x509authority @@ -0,0 +1,18 @@ +#!/bin/bash + +# Fetch the prepared authority ID +prepared_authority=$(docker compose exec -t root-server \ + /opt/spire/bin/spire-server \ + localauthority x509 show -output json | jq -r .prepared.authority_id) || fail-now "Failed to fetch prepared authority ID" + +# Activate the authority +activated_authority=$(docker compose exec -t root-server \ + /opt/spire/bin/spire-server \ + localauthority x509 activate -authorityID "${prepared_authority}" \ + -output json | jq -r .activated_authority.authority_id) || fail-now "Failed to activate authority" + +log-info "Activated authority: ${activated_authority}" + +# Check logs for specific lines +check-log-line root-server "X509 CA activated|local_authority_id=${prepared_authority}" +check-log-line root-server "Successfully rotated X\.509 CA" diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/12-taint-x509authority b/test/integration/cassandra-suites/force-rotation-self-signed/12-taint-x509authority new file mode 100755 index 0000000000..639b15f76b --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/12-taint-x509authority @@ -0,0 +1,68 @@ +#!/bin/bash + +check-logs() { + local component=$1 + shift + for log in "$@"; do + check-log-line "$component" "$log" + done +} + +# Fetch old authority ID +old_authority=$(docker compose exec -T root-server \ + /opt/spire/bin/spire-server \ + localauthority x509 show -output json | jq .old.authority_id -r) || fail-now "Failed to fetch old authority ID" + +# Taint the old authority +docker compose exec -T root-server \ + /opt/spire/bin/spire-server \ + localauthority x509 taint -authorityID "${old_authority}" || fail-now "Failed to taint old authority" + +# Root server logs +check-logs root-server \ + "X\.509 authority tainted successfully|local_authority_id=${old_authority}" \ + "Server SVID signed using a tainted authority, forcing rotation of the Server SVID" + +# Root agent logs +check-logs root-agent \ + "New tainted X.509 authorities found|subject_key_ids=${old_authority}" \ + "Scheduled rotation for SVID entries due to tainted X\.509 authorities|count=3" \ + "Agent SVID is tainted by a root authority, forcing rotation" + +# Verify workloads are rotated + +# Intermediate A server and agent logs +check-logs intermediateA-server \ + "Current root CA is signed by a tainted upstream authority, preparing rotation" \ + "Server SVID signed using a tainted authority, forcing rotation of the Server SVID" +check-logs intermediateA-agent \ + "New tainted X\.509 authorities found|subject_key_ids=${old_authority}" \ + "Scheduled rotation for SVID entries due to tainted X.509 authorities|count=2" \ + "Agent SVID is tainted by a root authority, forcing rotation" + +# Intermediate B server and agent logs +check-logs intermediateB-server \ + "Current root CA is signed by a tainted upstream authority, preparing rotation" \ + "Server SVID signed using a tainted authority, forcing rotation of the Server SVID" +check-logs intermediateB-agent \ + "New tainted X\.509 authorities found|subject_key_ids=${old_authority}" \ + "Scheduled rotation for SVID entries due to tainted X\.509 authorities|count=2" \ + "Agent SVID is tainted by a root authority, forcing rotation" + +# Leaf A server and agent logs +check-logs leafA-server \ + "Current root CA is signed by a tainted upstream authority, preparing rotation" \ + "Server SVID signed using a tainted authority, forcing rotation of the Server SVID" +check-logs leafA-agent \ + "New tainted X.509 authorities found|subject_key_ids=${old_authority}" \ + "Scheduled rotation for SVID entries due to tainted X\.509 authorities|count=1" \ + "Agent SVID is tainted by a root authority, forcing rotation" + +# Leaf B server and agent logs +check-logs leafB-server \ + "Current root CA is signed by a tainted upstream authority, preparing rotation" \ + "Server SVID signed using a tainted authority, forcing rotation of the Server SVID" +check-logs leafB-agent \ + "New tainted X.509 authorities found|subject_key_ids=${old_authority}" \ + "Scheduled rotation for SVID entries due to tainted X\.509 authorities|count=1" \ + "Agent SVID is tainted by a root authority, forcing rotation" diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/13-verify-svids-rotates b/test/integration/cassandra-suites/force-rotation-self-signed/13-verify-svids-rotates new file mode 100755 index 0000000000..6ebc2701c4 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/13-verify-svids-rotates @@ -0,0 +1,71 @@ +#!/bin/bash + +MAX_RETRIES=10 +RETRY_DELAY=2 # seconds between retries + +fetch-x509-authorities() { + local server=$1 + docker compose exec -T "$server" /opt/spire/bin/spire-server bundle show -output json | jq .x509_authorities +} + +verify-svid() { + local agent=$1 + local agent_dir=$2 + + docker compose exec -u 1001 -T "$agent" \ + /opt/spire/bin/spire-agent api fetch x509 \ + -socketPath /opt/spire/sockets/workload_api_${agent}.sock \ + -write /tmp || fail-now "x509-SVID check failed for $agent" + + docker compose exec -T "$agent" \ + openssl verify -verbose -CAfile /opt/spire/conf/agent/non-tainted.pem \ + -untrusted /tmp/svid.0.pem /tmp/svid.0.pem +} + +check-tainted-authorities() { + local server=$1 + local agent=$2 + local agent_dir=$3 + + log-debug "Checking tainted authorities for $server and $agent" + x509_authorities=$(fetch-x509-authorities "$server") + + echo "$x509_authorities" | jq '.[] | select(.tainted == true)' || fail-now "Tainted authority not found" + non_tainted_found=$(echo "$x509_authorities" | jq '.[] | select(.tainted == false)') || fail-now "Non-tainted authority not found" + + echo "$non_tainted_found" | jq -r .asn1 | base64 -d | openssl x509 -inform der > "$agent_dir/agent/non-tainted.pem" + + RETRY_COUNT=0 + + while [[ $RETRY_COUNT -lt $MAX_RETRIES ]]; do + verify-svid "$agent" "$agent_dir" + + if [ $? -eq 0 ]; then + log-info "SVID rotated" + break + else + RETRY_COUNT=$((RETRY_COUNT + 1)) + log-debug "Verification failed, retrying in $RETRY_DELAY seconds... ($RETRY_COUNT/$MAX_RETRIES)" + sleep $RETRY_DELAY + fi + + if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + fail-now "Certificate verification failed after $MAX_RETRIES attempts." + fi + done +} + +# Root +check-tainted-authorities "root-server" "root-agent" "root" + +# IntermediateA +check-tainted-authorities "intermediateA-server" "intermediateA-agent" "intermediateA" + +# IntermediateB +check-tainted-authorities "intermediateB-server" "intermediateB-agent" "intermediateB" + +# LeafA +check-tainted-authorities "leafA-server" "leafA-agent" "leafA" + +# LeafB +check-tainted-authorities "leafB-server" "leafB-agent" "leafB" diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/14-revoke-x509authority b/test/integration/cassandra-suites/force-rotation-self-signed/14-revoke-x509authority new file mode 100755 index 0000000000..2c689dcb87 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/14-revoke-x509authority @@ -0,0 +1,61 @@ +#!/bin/bash + +MAX_RETRIES=10 +RETRY_DELAY=1 # seconds between retries + +get-x509-authorities-count() { + local server=$1 + docker compose exec -T $server /opt/spire/bin/spire-server bundle show -output json | jq '.x509_authorities | length' +} + +old_authority=$(docker compose exec -T root-server \ + /opt/spire/bin/spire-server localauthority x509 show -output json | jq .old.authority_id -r) || fail-now "Failed to get old authority" + +log-debug "Old authority: $old_authority" + +x509_authorities_count=$(get-x509-authorities-count root-server) + +if [ $x509_authorities_count -eq 2 ]; then + log-debug "Two X.509 Authorities found" +else + fail-now "Expected to be two X.509 Authorities. Found $x509_authorities_count." +fi + +tainted_found=$(docker compose exec -T root-server /opt/spire/bin/spire-server bundle show -output json | jq '.x509_authorities[] | select(.tainted == true)') + +if [[ -z "$tainted_found" ]]; then + fail-now "Tainted authority expected" +fi + +docker compose exec -T root-server \ + /opt/spire/bin/spire-server localauthority x509 revoke -authorityID $old_authority -output json || fail-now "Failed to revoke authority" + +check-log-line root-server "X\.509 authority revoked successfully|local_authority_id=$old_authority" +check-log-line intermediateA-server "X\.509 authority revoked|subject_key_id=$old_authority" +check-log-line intermediateB-server "X\.509 authority revoked|subject_key_id=$old_authority" +check-log-line leafA-server "X\.509 authority revoked|subject_key_id=$old_authority" +check-log-line leafB-server "X\.509 authority revoked|subject_key_id=$old_authority" + +servers=("root-server" "intermediateA-server" "intermediateB-server" "leafA-server" "leafB-server") + +for server in "${servers[@]}"; do + retry_count=0 + while [[ $retry_count -lt $MAX_RETRIES ]]; do + log-debug "Checking if X.509 Authority is revoked on $server" + x509_authorities_count=$(get-x509-authorities-count $server) + + if [ $x509_authorities_count -eq 1 ]; then + log-debug "Revoked X.509 Authority successfully on $server" + break + else + retry_count=$((retry_count + 1)) + echo "Revocation is not propagated on $server, retrying in $RETRY_DELAY seconds... ($retry_count/$MAX_RETRIES)" + sleep $RETRY_DELAY + fi + + # Fail if retries exceed the maximum + if [ $retry_count -eq $MAX_RETRIES ]; then + fail-now "Revocation is not propagated on $server failed after $MAX_RETRIES attempts." + fi + done +done diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/15-verify-revoked-x509authority b/test/integration/cassandra-suites/force-rotation-self-signed/15-verify-revoked-x509authority new file mode 100755 index 0000000000..4823bf07ec --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/15-verify-revoked-x509authority @@ -0,0 +1,61 @@ +#!/bin/bash + +MAX_RETRIES=10 +RETRY_DELAY=2 # seconds between retries + +fetch-active-authority() { + docker compose exec -T root-server \ + /opt/spire/bin/spire-server localauthority x509 show -output json | jq -r .active.authority_id +} + +validate-agent() { + local agent=$1 + local retry_count=0 + + while [[ $retry_count -lt $MAX_RETRIES ]]; do + docker compose exec -u 1001 -T $agent \ + /opt/spire/bin/spire-agent api fetch x509 \ + -socketPath /opt/spire/sockets/workload_api_${agent}.sock \ + -write /tmp || fail-now "x509-SVID check failed for $agent" + + local bundle_count=$(docker compose exec -T $agent \ + openssl storeutl -noout -text -certs /tmp/bundle.0.pem | grep -c "Certificate:") + if [ $bundle_count -eq 1 ]; then + log-debug "Validation successful for $agent: There is exactly one certificate in the chain." + return 0 + else + log-debug "Validation failed for $agent: Expected 1 certificate, but found $bundle_count. Retrying in $RETRY_DELAY seconds... ($retry_count/$MAX_RETRIES)" + fi + + retry_count=$((retry_count + 1)) + sleep $RETRY_DELAY + + if [ $retry_count -eq $MAX_RETRIES ]; then + fail-now "Validation failed for $agent: Expected 1 certificate, but found $bundle_count." + fi + done +} + +check_ski() { + local agent=$1 + local old_authority=$2 + + local ski=$(docker compose exec -T $agent \ + openssl x509 -in /tmp/bundle.0.pem -text | grep \ + -A 1 'Subject Key Identifier' | tail -n 1 | tr -d ' ' | tr -d ':' | tr '[:upper:]' '[:lower:]') + + if [ "$ski" == "$old_authority" ]; then + log-debug "Subject Key Identifier matches for $agent: $ski" + else + fail-now "Subject Key Identifier does not match for $agent. Found: $ski Expected: $old_authority" + fi +} + +active_authority=$(fetch-active-authority) +log-debug "Active authority: $active_authority" + +agents=("root-agent" "intermediateA-agent" "intermediateB-agent" "leafA-agent" "leafB-agent") +for agent in "${agents[@]}"; do + validate-agent "$agent" + check_ski "$agent" "$active_authority" +done diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/Dockerfile b/test/integration/cassandra-suites/force-rotation-self-signed/Dockerfile new file mode 100644 index 0000000000..d3e3896276 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/Dockerfile @@ -0,0 +1,4 @@ +FROM alpine:3.18 AS nested-agent-alpine +RUN apk add --no-cache --update openssl +COPY --from=spire-agent:latest-local /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent +ENTRYPOINT ["/opt/spire/bin/spire-agent", "run"] diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/README.md b/test/integration/cassandra-suites/force-rotation-self-signed/README.md new file mode 100644 index 0000000000..ad3c47dceb --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/README.md @@ -0,0 +1,26 @@ +# Force rotation with selt-signed X.509 authority Suite + +## Description + +This test suite configures a self-signed CA in the root-server, +and exercises forced rotation of CA certificates across nested servers. +The integration test is structured with three layers of server/agents pairs: + + root-server + | + root-agent + / \ + intermediateA-server intermediateA-server + | | + intermediateA-agent intermediateA-agent + | | + leafA-server leafA-server + | | + leafA-agent leafA-agent + +## Test steps + +1. **Prepare a new X.509 authority**: Validate that the new X.509 authority is propagated to all nested servers. +2. **Activate the new X.509 authority**: Ensure that the new X.509 authority becomes active. +3. **Taint the old X.509 authority**: Confirm that the tainted authority is propagated to nested servers and that all X.509 SVIDs are rotated accordingly. +4. **Revoke the tainted X.509 authority**: Validate that the revocation instruction is propagated to all nested servers, and that all SVIDs have the revoked authority removed. diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/docker-compose.yaml b/test/integration/cassandra-suites/force-rotation-self-signed/docker-compose.yaml new file mode 100644 index 0000000000..35120df3e8 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/docker-compose.yaml @@ -0,0 +1,163 @@ +services: + # Root + root-server: + image: spire-server:latest-local + hostname: root-server + env_file: + - ../.env + volumes: + - ./root/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + root-agent: + # Share the host pid namespace so this agent can attest the intermediate servers + pid: "host" + use_api_socket: true + privileged: true + image: nested-agent-alpine + env_file: + - ../.env + depends_on: ["root-server"] + hostname: root-agent + volumes: + # Share root agent socket to be acceded by leafA and leafB servers + - rootSocket:/opt/spire/sockets:rw + - ./root/agent:/opt/spire/conf/agent + - /var/run/docker.sock:/var/run/docker.sock + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + # Make sure that we can access the Docker daemon socket + user: 0:0 + # IntermediateA + intermediateA-server: + # Share the host pid namespace so this server can be attested by the root agent + pid: "host" + image: spire-server:latest-local + env_file: + - ../.env + hostname: intermediateA-server + labels: + # label to attest server against root-agent + - org.integration.name=intermediateA + depends_on: ["root-server","root-agent"] + volumes: + # Add root agent socket + - rootSocket:/opt/spire/sockets:rw + - ./intermediateA/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + intermediateA-agent: + # Share the host pid namespace so this agent can attest the leafA server + pid: "host" + use_api_socket: true + privileged: true + image: nested-agent-alpine + env_file: + - ../.env + hostname: intermediateA-agent + depends_on: ["intermediateA-server"] + volumes: + - ./intermediateA/agent:/opt/spire/conf/agent + # Share intermediateA agent socket to be acceded by leafA server + - intermediateASocket:/opt/spire/sockets:rw + - /var/run/docker.sock:/var/run/docker.sock + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + # LeafA + leafA-server: + # Share the host pid namespace so this server can be attested by the intermediateA agent + pid: "host" + image: spire-server:latest-local + env_file: + - ../.env + hostname: leafA-server + labels: + # Label to attest server against intermediateA-agent + - org.integration.name=leafA + depends_on: ["intermediateA-server","intermediateA-agent"] + volumes: + # Add intermediatA agent socket + - intermediateASocket:/opt/spire/sockets:rw + - ./leafA/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + leafA-agent: + image: nested-agent-alpine + use_api_socket: true + privileged: true + env_file: + - ../.env + hostname: leafA-agent + depends_on: ["intermediateA-server"] + volumes: + - ./leafA/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + # IntermediateB + intermediateB-server: + # Share the host pid namespace so this server can be attested by the root agent + pid: "host" + image: spire-server:latest-local + env_file: + - ../.env + hostname: intermediateB-server + depends_on: ["root-server","root-agent"] + labels: + # Label to attest server against root-agent + - org.integration.name=intermediateB + volumes: + # Add root agent socket + - rootSocket:/opt/spire/sockets:rw + - ./intermediateB/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + intermediateB-agent: + # Share the host pid namespace so this agent can attest the leafB server + pid: "host" + image: nested-agent-alpine + use_api_socket: true + privileged: true + env_file: + - ../.env + hostname: intermediateB-agent + depends_on: ["intermediateB-server"] + volumes: + - ./intermediateB/agent:/opt/spire/conf/agent + # Share intermediateB agent socket to be acceded by leafB server + - intermediateBSocket:/opt/spire/sockets:rw + - /var/run/docker.sock:/var/run/docker.sock + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + # leafB + leafB-server: + # Share the host pid namespace so this server can be attested by the intermediateB agent + pid: "host" + image: spire-server:latest-local + env_file: + - ../.env + hostname: leafB-server + depends_on: ["intermediateB-server","intermediateB-agent"] + labels: + # Label to attest server against intermediateB-agent + - org.integration.name=leafB + volumes: + # Add intermediateB agent socket + - intermediateBSocket:/opt/spire/sockets:rw + - ./leafB/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + leafB-agent: + image: nested-agent-alpine + env_file: + - ../.env + hostname: leafB-agent + depends_on: ["leafB-server"] + volumes: + - ./leafB/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] +volumes: + # Shared volumes to share sockets between servers and agents + rootSocket: + intermediateASocket: + intermediateBSocket: diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/intermediateA/agent/agent.conf b/test/integration/cassandra-suites/force-rotation-self-signed/intermediateA/agent/agent.conf new file mode 100644 index 0000000000..e93a9b47fd --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/intermediateA/agent/agent.conf @@ -0,0 +1,31 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "intermediateA-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api_intermediateA-agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } + WorkloadAttestor "docker" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/intermediateA/server/server.conf b/test/integration/cassandra-suites/force-rotation-self-signed/intermediateA/server/server.conf new file mode 100644 index 0000000000..e6d876b9b0 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/intermediateA/server/server.conf @@ -0,0 +1,35 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + # ca_ttl should not exceed the upstream authority's SVID lifetime + ca_ttl = "36h" + # default_x509_svid_ttl is recommended to be one-sixth of ca_ttl + default_x509_svid_ttl = "6h" +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/opt/spire/data/server/datastore.sqlite3" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } + UpstreamAuthority "spire" { + plugin_data = { + server_address = "root-server" + server_port = 8081 + workload_api_socket = "/opt/spire/sockets/workload_api_root-agent.sock" + } + } +} diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/intermediateB/agent/agent.conf b/test/integration/cassandra-suites/force-rotation-self-signed/intermediateB/agent/agent.conf new file mode 100644 index 0000000000..56a6b0e6d4 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/intermediateB/agent/agent.conf @@ -0,0 +1,31 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "intermediateB-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api_intermediateB-agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } + WorkloadAttestor "docker" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/intermediateB/server/server.conf b/test/integration/cassandra-suites/force-rotation-self-signed/intermediateB/server/server.conf new file mode 100644 index 0000000000..e6d876b9b0 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/intermediateB/server/server.conf @@ -0,0 +1,35 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + # ca_ttl should not exceed the upstream authority's SVID lifetime + ca_ttl = "36h" + # default_x509_svid_ttl is recommended to be one-sixth of ca_ttl + default_x509_svid_ttl = "6h" +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/opt/spire/data/server/datastore.sqlite3" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } + UpstreamAuthority "spire" { + plugin_data = { + server_address = "root-server" + server_port = 8081 + workload_api_socket = "/opt/spire/sockets/workload_api_root-agent.sock" + } + } +} diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/leafA/agent/agent.conf b/test/integration/cassandra-suites/force-rotation-self-signed/leafA/agent/agent.conf new file mode 100644 index 0000000000..3479eced12 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/leafA/agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "leafA-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api_leafA-agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/leafA/server/server.conf b/test/integration/cassandra-suites/force-rotation-self-signed/leafA/server/server.conf new file mode 100644 index 0000000000..d0240c020c --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/leafA/server/server.conf @@ -0,0 +1,35 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + # ca_ttl should not exceed the upstream authority's SVID lifetime + ca_ttl = "6h" + # default_x509_svid_ttl is recommended to be one-sixth of ca_ttl + default_x509_svid_ttl = "1h" +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/opt/spire/data/server/datastore.sqlite3" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } + UpstreamAuthority "spire" { + plugin_data = { + server_address = "intermediateA-server" + server_port = 8081 + workload_api_socket = "/opt/spire/sockets/workload_api_intermediateA-agent.sock" + } + } +} diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/leafB/agent/agent.conf b/test/integration/cassandra-suites/force-rotation-self-signed/leafB/agent/agent.conf new file mode 100644 index 0000000000..fd08e41ef6 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/leafB/agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "leafB-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api_leafB-agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/leafB/server/server.conf b/test/integration/cassandra-suites/force-rotation-self-signed/leafB/server/server.conf new file mode 100644 index 0000000000..2b18f0638e --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/leafB/server/server.conf @@ -0,0 +1,35 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + # ca_ttl should not exceed the upstream authority's SVID lifetime + ca_ttl = "6h" + # default_x509_svid_ttl is recommended to be one-sixth of ca_ttl + default_x509_svid_ttl = "1h" +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/opt/spire/data/server/datastore.sqlite3" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } + UpstreamAuthority "spire" { + plugin_data = { + server_address = "intermediateB-server" + server_port = 8081 + workload_api_socket = "/opt/spire/sockets/workload_api_intermediateB-agent.sock" + } + } +} diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/root/agent/agent.conf b/test/integration/cassandra-suites/force-rotation-self-signed/root/agent/agent.conf new file mode 100644 index 0000000000..1b01564203 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/root/agent/agent.conf @@ -0,0 +1,32 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "root-server" + server_port = "8081" + socket_path ="/opt/spire/sockets/workload_api_root-agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "docker" { + plugin_data { + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } + +} diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/root/server/server.conf b/test/integration/cassandra-suites/force-rotation-self-signed/root/server/server.conf new file mode 100644 index 0000000000..691c469ba4 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/root/server/server.conf @@ -0,0 +1,49 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + # Set big numbers, to never go into regular rotations + ca_ttl = "216h" + default_x509_svid_ttl = "36h" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/force-rotation-self-signed/teardown b/test/integration/cassandra-suites/force-rotation-self-signed/teardown new file mode 100755 index 0000000000..f28d5eaffd --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-self-signed/teardown @@ -0,0 +1,7 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi + +docker-down diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/00-setup b/test/integration/cassandra-suites/force-rotation-upstream-authority/00-setup new file mode 100755 index 0000000000..c9335c895a --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/00-setup @@ -0,0 +1,41 @@ +#!/bin/bash + +set -e + +# Function to generate a new EC key and self-signed certificate +generate_cert() { + local key_path=$1 + local crt_path=$2 + + openssl ecparam -name secp384r1 -genkey -noout -out "${key_path}" + openssl req -new -x509 -key "${key_path}" -out "${crt_path}" -days 1825 -subj "/C=US/ST=/L=/O=SPIFFE/OU=/CN=/" -config <( +cat <<-EOF +[req] +default_bits = 2048 +default_md = sha512 +distinguished_name = dn +[ dn ] +[alt_names] +URI.1 = spiffe://local +[v3_req] +subjectKeyIdentifier=hash +basicConstraints=critical,CA:TRUE +keyUsage=critical,keyCertSign,cRLSign +subjectAltName = @alt_names +EOF + ) -extensions 'v3_req' + + chmod 644 "${key_path}" "${crt_path}" +} + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent + +# Generate dummy upstream CA +generate_cert "conf/server/old_upstream_ca.key" "conf/server/old_upstream_ca.crt" + +# Generate new upstream CA +generate_cert "conf/server/new_upstream_ca.key" "conf/server/new_upstream_ca.crt" + +cp conf/server/old_upstream_ca.crt conf/server/dummy_upstream_ca.crt +cp conf/server/old_upstream_ca.key conf/server/dummy_upstream_ca.key + diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/01-start-server b/test/integration/cassandra-suites/force-rotation-upstream-authority/01-start-server new file mode 100755 index 0000000000..a59ecc0d39 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/01-start-server @@ -0,0 +1,4 @@ +#!/bin/bash + +docker-spire-server-up spire-server +sleep 100 diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/02-bootstrap-agent b/test/integration/cassandra-suites/force-rotation-upstream-authority/02-bootstrap-agent new file mode 100755 index 0000000000..8ee7d32c26 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/02-bootstrap-agent @@ -0,0 +1,5 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/03-start-agent b/test/integration/cassandra-suites/force-rotation-upstream-authority/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/04-create-workload-entry b/test/integration/cassandra-suites/force-rotation-upstream-authority/04-create-workload-entry new file mode 100755 index 0000000000..661c0ea6d8 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/04-create-workload-entry @@ -0,0 +1,14 @@ +#!/bin/bash + +log-debug "creating registration entry..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload" \ + -selector "unix:uid:0" \ + -x509SVIDTTL 0 +check-synced-entry "spire-agent" "spiffe://domain.test/workload" + +log-info "checking X509-SVID" +docker compose exec -T spire-agent \ + /opt/spire/bin/spire-agent api fetch x509 || fail-now "SVID check failed" diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/05-update-upstream-authority b/test/integration/cassandra-suites/force-rotation-upstream-authority/05-update-upstream-authority new file mode 100755 index 0000000000..fa5021e0b2 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/05-update-upstream-authority @@ -0,0 +1,6 @@ +#!/bin/bash + +# Update upstream authority +cp conf/server/new_upstream_ca.crt conf/server/dummy_upstream_ca.crt +cp conf/server/new_upstream_ca.key conf/server/dummy_upstream_ca.key + diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/06-prepare-x509-authority b/test/integration/cassandra-suites/force-rotation-upstream-authority/06-prepare-x509-authority new file mode 100755 index 0000000000..caefaf37f0 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/06-prepare-x509-authority @@ -0,0 +1,43 @@ +#!/bin/bash + +# Initial check for x509 authorities in spire-server +x509_authorities=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show -output json | jq '.x509_authorities' -c) + +amount_bundles=$(echo "$x509_authorities" | jq length) + +# Ensure only one bundle is present at the start +if [[ $amount_bundles -ne 1 ]]; then + fail-now "Only one bundle expected at start" +fi + +# Prepare authority +prepared_authority_id=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server localauthority x509 prepare -output json | jq -r .prepared_authority.authority_id) + +# Verify that the prepared authority is logged +searching="X509 CA prepared.|local_authority_id=${prepared_authority_id}" +check-log-line spire-server "$searching" + +# Check for updated x509 authorities in spire-server +x509_authorities=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show -output json | jq '.x509_authorities' -c) +amount_bundles=$(echo "$x509_authorities" | jq length) + +# Ensure two bundles are present after preparation +if [[ $amount_bundles -ne 2 ]]; then + fail-now "Two bundles expected after prepare" +fi + +new_dummy_ca_skid=$(openssl x509 -in conf/server/new_upstream_ca.crt -text | grep \ + -A 1 'Subject Key Identifier' | tail -n 1 | tr -d ' ' | tr -d ':' | tr '[:upper:]' '[:lower:]') + +upstream_authority_id=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server \ + localauthority x509 show -output json | jq .prepared.upstream_authority_subject_key_id -r) + +if [ "$new_dummy_ca_skid" == "$upstream_authority_id" ]; then + log-debug "Prepared X.509 authority is using new upstream authorityh" +else + fail-now "Subject Key Identifier does not match. Found: $upstream_authority_id Expected: $new_dummy_ca_skid" +fi diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/07-activate-x509-authority b/test/integration/cassandra-suites/force-rotation-upstream-authority/07-activate-x509-authority new file mode 100755 index 0000000000..6a28a4fd80 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/07-activate-x509-authority @@ -0,0 +1,22 @@ +#!/bin/bash + +# Fetch the prepared authority ID +prepared_authority=$(docker compose exec -t spire-server \ + /opt/spire/bin/spire-server \ + localauthority x509 show -output json | jq -r .prepared.authority_id) || fail-now "Failed to fetch prepared authority ID" +upstream_authority=$(docker compose exec -t spire-server \ + /opt/spire/bin/spire-server \ + localauthority x509 show -output json | jq -r .prepared.upstream_authority_subject_key_id) || fail-now "Failed to fetch prepared authority ID" + +# Activate the authority +activated_authority=$(docker compose exec -t spire-server \ + /opt/spire/bin/spire-server \ + localauthority x509 activate -authorityID "${prepared_authority}" \ + -output json | jq -r .activated_authority.authority_id) || fail-now "Failed to activate authority" + +log-info "Activated authority: ${activated_authority}" + +# Check logs for specific lines +check-log-line spire-server "X509 CA activated|local_authority_id=${prepared_authority}|upstream_authority_id=${upstream_authority}" +check-log-line spire-server "Successfully rotated X\.509 CA" + diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/08-taint-upstream-authority b/test/integration/cassandra-suites/force-rotation-upstream-authority/08-taint-upstream-authority new file mode 100755 index 0000000000..6508f5a308 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/08-taint-upstream-authority @@ -0,0 +1,33 @@ +#!/bin/bash + +check-logs() { + local component=$1 + shift + for log in "$@"; do + check-log-line "$component" "$log" + done +} + +# Fetch old authority ID +old_upstream_authority=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server \ + localauthority x509 show -output json | jq -r .old.upstream_authority_subject_key_id) || fail-now "Failed to fetch old upstrem authority ID" + +log-debug "Old upstream authority: $old_upstream_authority" + +# Taint the old authority +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server \ + upstreamauthority taint -subjectKeyID "${old_upstream_authority}" || fail-now "Failed to taint old authority" + +# Root server logs +check-logs spire-server \ + "X\.509 upstream authority tainted successfully|subject_key_id=${old_upstream_authority}" \ + "Server SVID signed using a tainted authority, forcing rotation of the Server SVID" + +# Root agent logs +check-logs spire-agent \ + "New tainted X.509 authorities found|subject_key_ids=${old_upstream_authority}" \ + "Scheduled rotation for SVID entries due to tainted X\.509 authorities|count=1" \ + "Agent SVID is tainted by a root authority, forcing rotation" + diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/09-verify-svid-rotation b/test/integration/cassandra-suites/force-rotation-upstream-authority/09-verify-svid-rotation new file mode 100755 index 0000000000..c85536ba31 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/09-verify-svid-rotation @@ -0,0 +1,53 @@ +#!/bin/bash + +MAX_RETRIES=10 +RETRY_DELAY=2 # seconds between retries + +fetch-x509-authorities() { + local server=$1 + docker compose exec -T "$server" /opt/spire/bin/spire-server bundle show -output json | jq .x509_authorities +} + +verify-svid() { + local agent=$1 + local agent_dir=$2 + + docker compose exec -T "$agent" \ + /opt/spire/bin/spire-agent api fetch x509 \ + -write $agent_dir || fail-now "x509-SVID check failed for $agent" + + openssl verify -verbose -CAfile conf/server/new_upstream_ca.crt \ + -untrusted ${agent_dir}/svid.0.pem ${agent_dir}/svid.0.pem +} + +check-tainted-authorities() { + local server=$1 + local agent=$2 + local agent_dir=$3 + + x509_authorities=$(fetch-x509-authorities "$server") + echo "$x509_authorities" | jq '.[] | select(.tainted == true)' || fail-now "Tainted authority not found" + + retry_count=0 + + while [[ $retry_count -lt $MAX_RETRIES ]]; do + verify-svid "$agent" "$agent_dir" + + if [ $? -eq 0 ]; then + log-info "SVID rotated" + break + else + retry_count=$((retry_count + 1)) + log-debug "Verification failed, retrying in $RETRY_DELAY seconds... ($retry_count/$MAX_RETRIES)" + sleep $RETRY_DELAY + fi + + if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + fail-now "Certificate verification failed after $MAX_RETRIES attempts." + fi + done +} + +# Root +check-tainted-authorities "spire-server" "spire-agent" "conf/agent" + diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/10-revoke-upstream-authority b/test/integration/cassandra-suites/force-rotation-upstream-authority/10-revoke-upstream-authority new file mode 100755 index 0000000000..39255cb4c3 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/10-revoke-upstream-authority @@ -0,0 +1,35 @@ +#!/bin/bash + +get-x509-authorities-count() { + local server=$1 +} + +old_upstream_authority=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server \ + localauthority x509 show -output json | jq -r .old.upstream_authority_subject_key_id) || fail-now "Failed to fetch old upstrem authority ID" + +log-debug "Old authority: $old_upstream_authority" + + +x509_authorities_count=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle \ + show -output json | jq '.x509_authorities | length') + +if [ $x509_authorities_count -eq 2 ]; then + log-debug "Two X.509 Authorities found" +else + fail-now "Expected to be two X.509 Authorities. Found $x509_authorities_count." +fi + +tainted_found=$(docker compose exec -T spire-server /opt/spire/bin/spire-server bundle show -output json | jq '.x509_authorities[] | select(.tainted == true)') + +if [[ -z "$tainted_found" ]]; then + fail-now "Tainted authority expected" +fi + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server upstreamauthority \ + revoke -subjectKeyID $old_upstream_authority -output json || fail-now "Failed to revoke upstream authority" + +check-log-line spire-server "X\.509 upstream authority successfully revoked|subject_key_id=$old_upstream_authority" + diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/11-verify-revoked-upstream-authority b/test/integration/cassandra-suites/force-rotation-upstream-authority/11-verify-revoked-upstream-authority new file mode 100755 index 0000000000..a31342631e --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/11-verify-revoked-upstream-authority @@ -0,0 +1,53 @@ +#!/bin/bash + +max_retries=10 +retry_delay=2 # seconds between retries + +validate-agent() { + local agent=$1 + local retry_count=0 + + while [[ $retry_count -lt $max_retries ]]; do + docker compose exec -T $agent \ + /opt/spire/bin/spire-agent api fetch x509 \ + -write /opt/spire/conf/agent || fail-now "x509-SVID check failed for $agent" + + local bundle_count=$(openssl storeutl -noout -text -certs conf/agent/bundle.0.pem | grep -c "Certificate:") + if [ $bundle_count -eq 1 ]; then + log-debug "Validation successful for $agent: There is exactly one certificate in the chain." + return 0 + else + log-debug "Validation failed for $agent: Expected 1 certificate, but found $bundle_count. Retrying in $retry_delay seconds... ($retry_count/$max_retries)" + fi + + retry_count=$((retry_count + 1)) + sleep $retry_delay + + if [ $retry_count -eq $max_retries ]; then + fail-now "Validation failed for $agent: Expected 1 certificate, but found $bundle_count." + fi + done +} + +check_ski() { + local agent=$1 + local old_authority=$2 + + local ski=$(openssl x509 -in conf/agent/bundle.0.pem -text | grep \ + -A 1 'Subject Key Identifier' | tail -n 1 | tr -d ' ' | tr -d ':' | tr '[:upper:]' '[:lower:]') + + if [ "$ski" == "$old_authority" ]; then + log-debug "Subject Key Identifier matches for $agent: $ski" + else + fail-now "Subject Key Identifier does not match for $agent. Found: $ski Expected: $old_authority" + fi +} + +active_upstream_authority=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server \ + localauthority x509 show -output json | jq -r .active.upstream_authority_subject_key_id) || fail-now "Failed to fetch old upstrem authority ID" + +log-debug "Active upstream authority: $active_upstream_authority" + +validate-agent spire-agent +check_ski spire-agent "$active_upstream_authority" diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/README.md b/test/integration/cassandra-suites/force-rotation-upstream-authority/README.md new file mode 100644 index 0000000000..90ffd64ac5 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/README.md @@ -0,0 +1,12 @@ +# Force rotation with Upstream Authority Test Suite + +## Description + +This test suite configures a disk-based Upstream Authority to validate the forced rotation and revocation of X.509 authorities. + +## Test steps + +1. **Prepare a new X.509 authority**: Verify that a new X.509 authority is successfully created. +2. **Activate the new X.509 authority**: Ensure that the new X.509 authority becomes the active authority. +3. **Taint the old X.509 authority**: Confirm that the old X.509 authority is marked as tainted, and verify that the taint instruction is propagated to the agent, triggering the rotation of all X.509 SVIDs. +4. **Revoke the tainted X.509 authority**: Validate that the revocation instruction is propagated to the agent and that all the SVIDs have the revoked authority removed. diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/conf/agent/agent.conf b/test/integration/cassandra-suites/force-rotation-upstream-authority/conf/agent/agent.conf new file mode 100644 index 0000000000..f79c4e9b06 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/conf/agent/agent.conf @@ -0,0 +1,26 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/conf/server/server.conf b/test/integration/cassandra-suites/force-rotation-upstream-authority/conf/server/server.conf new file mode 100644 index 0000000000..2f71f06cc9 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/conf/server/server.conf @@ -0,0 +1,54 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "24h" + default_x509_svid_ttl = "8h" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } + UpstreamAuthority "disk" { + plugin_data { + key_file_path = "./conf/server/dummy_upstream_ca.key" + cert_file_path = "./conf/server/dummy_upstream_ca.crt" + } + } +} diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/docker-compose.yaml b/test/integration/cassandra-suites/force-rotation-upstream-authority/docker-compose.yaml new file mode 100644 index 0000000000..d8c2241661 --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + spire-server: + image: spire-server:latest-local + env_file: + - ../.env + hostname: spire-server + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/force-rotation-upstream-authority/teardown b/test/integration/cassandra-suites/force-rotation-upstream-authority/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/force-rotation-upstream-authority/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/ghostunnel-federation/00-setup b/test/integration/cassandra-suites/ghostunnel-federation/00-setup new file mode 100755 index 0000000000..6d38a34aa5 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/00-setup @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/downstream/server conf/downstream/agent +"${ROOTDIR}/setup/x509pop/setup.sh" conf/upstream/server conf/upstream/agent + +docker build --target socat-ghostunnel-agent-mashup -t socat-ghostunnel-agent-mashup . diff --git a/test/integration/cassandra-suites/ghostunnel-federation/01-start-servers b/test/integration/cassandra-suites/ghostunnel-federation/01-start-servers new file mode 100755 index 0000000000..f4777a63c5 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/01-start-servers @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up upstream-spire-server downstream-spire-server diff --git a/test/integration/cassandra-suites/ghostunnel-federation/02-bootstrap-federation-and-agents b/test/integration/cassandra-suites/ghostunnel-federation/02-bootstrap-federation-and-agents new file mode 100755 index 0000000000..a22cb104f7 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/02-bootstrap-federation-and-agents @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +log-debug "bootstrapping downstream agent..." +docker compose exec -T downstream-spire-server \ + /opt/spire/bin/spire-server bundle show > conf/downstream/agent/bootstrap.crt + +log-debug "bootstrapping upstream agent..." +docker compose exec -T upstream-spire-server \ + /opt/spire/bin/spire-server bundle show > conf/upstream/agent/bootstrap.crt + +log-debug "bootstrapping bundle from downstream to upstream server..." +docker compose exec -T downstream-spire-server \ + /opt/spire/bin/spire-server bundle show -format spiffe > conf/upstream/server/downstream-domain.test.bundle + +# On macOS, there can be a delay propagating the file on the bind mount to the other container +sleep 1 + +docker compose exec -T upstream-spire-server \ + /opt/spire/bin/spire-server bundle set -format spiffe -id spiffe://downstream-domain.test -path /opt/spire/conf/server/downstream-domain.test.bundle + +log-debug "bootstrapping bundle from upstream to downstream server..." +docker compose exec -T upstream-spire-server \ + /opt/spire/bin/spire-server bundle show -format spiffe > conf/downstream/server/upstream-domain.test.bundle + +# On macOS, there can be a delay propagating the file on the bind mount to the other container +sleep 1 + +docker compose exec -T downstream-spire-server \ + /opt/spire/bin/spire-server bundle set -format spiffe -id spiffe://upstream-domain.test -path /opt/spire/conf/server/upstream-domain.test.bundle diff --git a/test/integration/cassandra-suites/ghostunnel-federation/03-start-remaining-containers b/test/integration/cassandra-suites/ghostunnel-federation/03-start-remaining-containers new file mode 100755 index 0000000000..4ddcd16ac5 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/03-start-remaining-containers @@ -0,0 +1,4 @@ +#!/bin/bash + +# bring up the rest +docker-up diff --git a/test/integration/cassandra-suites/ghostunnel-federation/04-create-workload-entries b/test/integration/cassandra-suites/ghostunnel-federation/04-create-workload-entries new file mode 100755 index 0000000000..00cc5b7342 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/04-create-workload-entries @@ -0,0 +1,21 @@ +#!/bin/bash + +set -o pipefail + +log-debug "creating registration entry for downstream workload..." +docker compose exec -T downstream-spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://downstream-domain.test/spire/agent/x509pop/$(fingerprint conf/downstream/agent/agent.crt.pem)" \ + -spiffeID "spiffe://downstream-domain.test/downstream-workload" \ + -selector "unix:uid:0" \ + -federatesWith "spiffe://upstream-domain.test" \ + -x509SVIDTTL 0 + +log-debug "creating registration entry for upstream workload..." +docker compose exec -T upstream-spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://upstream-domain.test/spire/agent/x509pop/$(fingerprint conf/upstream/agent/agent.crt.pem)" \ + -spiffeID "spiffe://upstream-domain.test/upstream-workload" \ + -selector "unix:uid:0" \ + -federatesWith "spiffe://downstream-domain.test" \ + -x509SVIDTTL 0 diff --git a/test/integration/cassandra-suites/ghostunnel-federation/05-check-workload-connectivity b/test/integration/cassandra-suites/ghostunnel-federation/05-check-workload-connectivity new file mode 100755 index 0000000000..ff4415be00 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/05-check-workload-connectivity @@ -0,0 +1,20 @@ +#!/bin/bash + +MAXCHECKSPERPORT=15 +CHECKINTERVAL=1 + +TRY() { docker compose exec -T downstream-workload /bin/sh -c 'echo HELLO | socat -u STDIN TCP:localhost:8000'; } +VERIFY() { docker compose exec -T upstream-workload cat /tmp/howdy | grep -q HELLO; } + +for ((i=1;i<=MAXCHECKSPERPORT;i++)); do + log-debug "Checking proxy ($i of $MAXCHECKSPERPORT max)..." + if TRY && VERIFY; then + log-info "Proxy OK" + docker compose exec -T upstream-workload rm /tmp/howdy + exit 0 + fi + + sleep "${CHECKINTERVAL}" +done + +fail-now "Proxy failed" diff --git a/test/integration/cassandra-suites/ghostunnel-federation/06-stop-servers b/test/integration/cassandra-suites/ghostunnel-federation/06-stop-servers new file mode 100755 index 0000000000..29d31e08ed --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/06-stop-servers @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e + +docker-stop downstream-spire-server +docker-stop upstream-spire-server diff --git a/test/integration/cassandra-suites/ghostunnel-federation/07-check-workload-connectivity b/test/integration/cassandra-suites/ghostunnel-federation/07-check-workload-connectivity new file mode 120000 index 0000000000..44f9eecb6b --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/07-check-workload-connectivity @@ -0,0 +1 @@ +05-check-workload-connectivity \ No newline at end of file diff --git a/test/integration/cassandra-suites/ghostunnel-federation/08-start-servers b/test/integration/cassandra-suites/ghostunnel-federation/08-start-servers new file mode 120000 index 0000000000..33ae056308 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/08-start-servers @@ -0,0 +1 @@ +01-start-servers \ No newline at end of file diff --git a/test/integration/cassandra-suites/ghostunnel-federation/09-check-workload-connectivity b/test/integration/cassandra-suites/ghostunnel-federation/09-check-workload-connectivity new file mode 120000 index 0000000000..44f9eecb6b --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/09-check-workload-connectivity @@ -0,0 +1 @@ +05-check-workload-connectivity \ No newline at end of file diff --git a/test/integration/cassandra-suites/ghostunnel-federation/10-stop-agents b/test/integration/cassandra-suites/ghostunnel-federation/10-stop-agents new file mode 100755 index 0000000000..df59c26c52 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/10-stop-agents @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e + +log-debug "stopping downstream agent" +docker compose exec -T downstream-workload supervisorctl --configuration /opt/supervisord/supervisord.conf stop spire-agent + +log-debug "stopping upstream agent" +docker compose exec -T upstream-workload supervisorctl --configuration /opt/supervisord/supervisord.conf stop spire-agent diff --git a/test/integration/cassandra-suites/ghostunnel-federation/11-check-workload-connectivity b/test/integration/cassandra-suites/ghostunnel-federation/11-check-workload-connectivity new file mode 120000 index 0000000000..44f9eecb6b --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/11-check-workload-connectivity @@ -0,0 +1 @@ +05-check-workload-connectivity \ No newline at end of file diff --git a/test/integration/cassandra-suites/ghostunnel-federation/12-start-agents b/test/integration/cassandra-suites/ghostunnel-federation/12-start-agents new file mode 100755 index 0000000000..5f05f86afe --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/12-start-agents @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e + +log-debug "starting downstream agent" +docker compose exec -T downstream-workload supervisorctl --configuration /opt/supervisord/supervisord.conf start spire-agent + +log-debug "starting upstream agent" +docker compose exec -T upstream-workload supervisorctl --configuration /opt/supervisord/supervisord.conf start spire-agent diff --git a/test/integration/cassandra-suites/ghostunnel-federation/13-check-workload-connectivity b/test/integration/cassandra-suites/ghostunnel-federation/13-check-workload-connectivity new file mode 120000 index 0000000000..44f9eecb6b --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/13-check-workload-connectivity @@ -0,0 +1 @@ +05-check-workload-connectivity \ No newline at end of file diff --git a/test/integration/cassandra-suites/ghostunnel-federation/Dockerfile b/test/integration/cassandra-suites/ghostunnel-federation/Dockerfile new file mode 100644 index 0000000000..3a80af7c2b --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/Dockerfile @@ -0,0 +1,11 @@ +FROM spire-agent:latest-local AS spire-agent + +FROM ghostunnel/ghostunnel:latest AS ghostunnel-latest + +FROM alpine/socat:latest AS socat-ghostunnel-agent-mashup +ENTRYPOINT ["/usr/bin/dumb-init", "supervisord", "--nodaemon", "--configuration", "/opt/supervisord/supervisord.conf"] +CMD [] +COPY --from=spire-agent /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent +COPY --from=ghostunnel-latest /usr/bin/ghostunnel /usr/bin/ghostunnel +RUN apk --no-cache --update add dumb-init +RUN apk --no-cache --update add supervisor diff --git a/test/integration/cassandra-suites/ghostunnel-federation/README.md b/test/integration/cassandra-suites/ghostunnel-federation/README.md new file mode 100644 index 0000000000..e251ed620f --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/README.md @@ -0,0 +1,19 @@ +# Ghostunnel + Federation Suite + +## Description + +Exercises [Ghostunnel](https://github.com/square/ghostunnel) SPIFFE Workload +API by wiring up two workloads that achieve connectivity using Ghostunnel +backed with identities and trust information retrieved from the SPIFFE Workload +API. + +The two workloads are in separate trust domains and are federated using the +SPIRE bundle endpoints. This enables each Ghostunnel proxy to authenticate +identities issued by the other trust domain. + +A custom container image is used that runs Ghostunnel, SPIRE agent, and socat +(acting as the workload). + +The SPIRE server and agent in each trust domain are brought down during different +portions of the test to ensure that as long as the SVID is valid, ghostunnel +connectivity is not disrupted by a little downtime. diff --git a/test/integration/cassandra-suites/ghostunnel-federation/conf/downstream/agent/agent.conf b/test/integration/cassandra-suites/ghostunnel-federation/conf/downstream/agent/agent.conf new file mode 100644 index 0000000000..6cad83986d --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/conf/downstream/agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "downstream-spire-server" + server_port = "8081" + socket_path ="/opt/shared/agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "downstream-domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/ghostunnel-federation/conf/downstream/ghostunnel/ghostunnel.flags b/test/integration/cassandra-suites/ghostunnel-federation/conf/downstream/ghostunnel/ghostunnel.flags new file mode 100644 index 0000000000..d4bbce6684 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/conf/downstream/ghostunnel/ghostunnel.flags @@ -0,0 +1,5 @@ +client +--use-workload-api-addr=unix:///opt/shared/agent.sock +--listen=localhost:8001 +--target=upstream-workload:8001 +--verify-uri=spiffe://upstream-domain.test/upstream-workload diff --git a/test/integration/cassandra-suites/ghostunnel-federation/conf/downstream/server/server.conf b/test/integration/cassandra-suites/ghostunnel-federation/conf/downstream/server/server.conf new file mode 100644 index 0000000000..f9876be8cb --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/conf/downstream/server/server.conf @@ -0,0 +1,41 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "downstream-domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "5m" + + federation { + bundle_endpoint { + port = 8443 + } + + federates_with "spiffe://upstream-domain.test" { + bundle_endpoint_url = "https://upstream-spire-server" + bundle_endpoint_profile "https_spiffe" { + endpoint_spiffe_id = "spiffe://upstream-domain.test/spire/server" + } + } + } +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/opt/spire/data/server/datastore.sqlite3" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "disk" { + plugin_data = { + keys_path = "/opt/spire/data/server/keys.json" + } + } +} diff --git a/test/integration/cassandra-suites/ghostunnel-federation/conf/downstream/supervisord/supervisord.conf b/test/integration/cassandra-suites/ghostunnel-federation/conf/downstream/supervisord/supervisord.conf new file mode 100644 index 0000000000..be85654f43 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/conf/downstream/supervisord/supervisord.conf @@ -0,0 +1,21 @@ +[supervisord] +nodaemon=true +loglevel=debug + +[unix_http_server] +file = /tmp/supervisor.sock + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl = unix:///tmp/supervisor.sock + +[program:spire-agent] +command = /opt/spire/bin/spire-agent run -config /opt/spire/conf/agent/agent.conf + +[program:ghostunnel] +command = /usr/bin/ghostunnel @/opt/ghostunnel/ghostunnel.flags + +[program:socat] +command = /usr/bin/socat -d -d TCP-LISTEN:8000,fork TCP:localhost:8001 diff --git a/test/integration/cassandra-suites/ghostunnel-federation/conf/upstream/agent/agent.conf b/test/integration/cassandra-suites/ghostunnel-federation/conf/upstream/agent/agent.conf new file mode 100644 index 0000000000..edec69ed52 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/conf/upstream/agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "upstream-spire-server" + server_port = "8081" + socket_path ="/opt/shared/agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "upstream-domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/ghostunnel-federation/conf/upstream/ghostunnel/ghostunnel.flags b/test/integration/cassandra-suites/ghostunnel-federation/conf/upstream/ghostunnel/ghostunnel.flags new file mode 100644 index 0000000000..4a1ed56b85 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/conf/upstream/ghostunnel/ghostunnel.flags @@ -0,0 +1,5 @@ +server +--use-workload-api-addr=unix:///opt/shared/agent.sock +--listen=0.0.0.0:8001 +--target=localhost:8000 +--allow-uri=spiffe://downstream-domain.test/downstream-workload diff --git a/test/integration/cassandra-suites/ghostunnel-federation/conf/upstream/server/server.conf b/test/integration/cassandra-suites/ghostunnel-federation/conf/upstream/server/server.conf new file mode 100644 index 0000000000..d3f09ab9a9 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/conf/upstream/server/server.conf @@ -0,0 +1,61 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "upstream-domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "5m" + experimental { + allow_pluggable_datastore = true + } + + federation { + bundle_endpoint { + port = 8443 + } + federates_with "downstream-domain.test" { + bundle_endpoint_url = "https://downstream-spire-server:8443" + bundle_endpoint_profile "https_spiffe" { + endpoint_spiffe_id = "spiffe://downstream-spire-server/spire/server" + } + } + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "disk" { + plugin_data = { + keys_path = "/opt/spire/data/server/keys.json" + } + } +} diff --git a/test/integration/cassandra-suites/ghostunnel-federation/conf/upstream/supervisord/supervisord.conf b/test/integration/cassandra-suites/ghostunnel-federation/conf/upstream/supervisord/supervisord.conf new file mode 100644 index 0000000000..f80c1977d0 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/conf/upstream/supervisord/supervisord.conf @@ -0,0 +1,21 @@ +[supervisord] +nodaemon=true +loglevel=debug + +[unix_http_server] +file = /tmp/supervisor.sock + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl = unix:///tmp/supervisor.sock + +[program:spire-agent] +command = /opt/spire/bin/spire-agent run -config /opt/spire/conf/agent/agent.conf + +[program:ghostunnel] +command = /usr/bin/ghostunnel @/opt/ghostunnel/ghostunnel.flags + +[program:socat] +command = /usr/bin/socat -d -d TCP-LISTEN:8000,fork OPEN:/tmp/howdy,creat,append diff --git a/test/integration/cassandra-suites/ghostunnel-federation/docker-compose.yaml b/test/integration/cassandra-suites/ghostunnel-federation/docker-compose.yaml new file mode 100644 index 0000000000..cf343e09f3 --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/docker-compose.yaml @@ -0,0 +1,35 @@ +services: + upstream-spire-server: + image: spire-server:latest-local + volumes: + - ./conf/upstream/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + env_file: + - ../.env + extra_hosts: + - "host.docker.internal:host-gateway" + downstream-spire-server: + image: spire-server:latest-local + volumes: + - ./conf/downstream/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + env_file: + - ../.env + extra_hosts: + - "host.docker.internal:host-gateway" + upstream-workload: + image: socat-ghostunnel-agent-mashup + env_file: + - ../.env + volumes: + - ./conf/upstream/supervisord:/opt/supervisord + - ./conf/upstream/ghostunnel:/opt/ghostunnel + - ./conf/upstream/agent:/opt/spire/conf/agent + downstream-workload: + image: socat-ghostunnel-agent-mashup + env_file: + - ../.env + volumes: + - ./conf/downstream/supervisord:/opt/supervisord + - ./conf/downstream/ghostunnel:/opt/ghostunnel + - ./conf/downstream/agent:/opt/spire/conf/agent diff --git a/test/integration/cassandra-suites/ghostunnel-federation/teardown b/test/integration/cassandra-suites/ghostunnel-federation/teardown new file mode 100755 index 0000000000..1e223d55da --- /dev/null +++ b/test/integration/cassandra-suites/ghostunnel-federation/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "${SUCCESS}" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/join-token/01-start-server b/test/integration/cassandra-suites/join-token/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/join-token/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/join-token/02-bootstrap-agents b/test/integration/cassandra-suites/join-token/02-bootstrap-agents new file mode 100755 index 0000000000..a55942aac2 --- /dev/null +++ b/test/integration/cassandra-suites/join-token/02-bootstrap-agents @@ -0,0 +1,17 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt + +log-info "generating join token..." +TOKEN=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server token generate -spiffeID spiffe://domain.test/node | awk '{print $2}' | tr -d '\r') + +# Inserts the join token into the agent configuration +log-debug "using join token ${TOKEN}..." +sed -i.bak "s#TOKEN#${TOKEN}#g" conf/agent/agent.conf + +# Duplicate the configuration for the "bad" agent. It will try to attest with +# the same join token later. +cp -R conf/agent conf/bad-agent diff --git a/test/integration/cassandra-suites/join-token/03-start-agent b/test/integration/cassandra-suites/join-token/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/join-token/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/join-token/04-create-workload-entry b/test/integration/cassandra-suites/join-token/04-create-workload-entry new file mode 100755 index 0000000000..a1d3b31555 --- /dev/null +++ b/test/integration/cassandra-suites/join-token/04-create-workload-entry @@ -0,0 +1,26 @@ +#!/bin/bash + +log-debug "creating registration entry..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/node" \ + -spiffeID "spiffe://domain.test/workload" \ + -selector "unix:uid:0" \ + -x509SVIDTTL 0 \ + -jwtSVIDTTL 0 + + +# Check at most 30 times (with one second in between) that the agent has +# successfully synced down the workload entry. +MAXCHECKS=30 +CHECKINTERVAL=1 +for ((i=1;i<=MAXCHECKS;i++)); do + log-info "checking for synced workload entry ($i of $MAXCHECKS max)..." + docker compose logs spire-agent + if docker compose logs spire-agent | grep "spiffe://domain.test/workload"; then + exit 0 + fi + sleep "${CHECKINTERVAL}" +done + +fail-now "timed out waiting for agent to sync down entry" diff --git a/test/integration/cassandra-suites/join-token/05-check-svid b/test/integration/cassandra-suites/join-token/05-check-svid new file mode 100755 index 0000000000..1eef411a2b --- /dev/null +++ b/test/integration/cassandra-suites/join-token/05-check-svid @@ -0,0 +1,5 @@ +#!/bin/bash + +log-info "checking X509-SVID..." +docker compose exec -T spire-agent \ + /opt/spire/bin/spire-agent api fetch x509 || fail-now "SVID check failed" diff --git a/test/integration/cassandra-suites/join-token/06-start-bad-agent b/test/integration/cassandra-suites/join-token/06-start-bad-agent new file mode 100755 index 0000000000..285c1c3f18 --- /dev/null +++ b/test/integration/cassandra-suites/join-token/06-start-bad-agent @@ -0,0 +1,15 @@ +#!/bin/bash + +docker-up bad-spire-agent + +MAXCHECKS=30 +CHECKINTERVAL=1 +for ((i=1;i<=MAXCHECKS;i++)); do + docker compose logs bad-spire-agent | tee bad-agent-logs + if grep -sq "failed to attest: join token does not exist or has already been used" bad-agent-logs; then + exit 0 + fi + sleep "${CHECKINTERVAL}" +done + +fail-now "timed out waiting for the bad spire agent to fail attestation" diff --git a/test/integration/cassandra-suites/join-token/README.md b/test/integration/cassandra-suites/join-token/README.md new file mode 100644 index 0000000000..0b25bbff06 --- /dev/null +++ b/test/integration/cassandra-suites/join-token/README.md @@ -0,0 +1,9 @@ +# Join Token Suite + +## Description + +This suite verifies that: + +- An agent can attest with a join token +- A join token vanity record can be used to register a workload +- A join token cannot be reused diff --git a/test/integration/cassandra-suites/join-token/conf/agent/agent.conf b/test/integration/cassandra-suites/join-token/conf/agent/agent.conf new file mode 100644 index 0000000000..f18b9d2d2f --- /dev/null +++ b/test/integration/cassandra-suites/join-token/conf/agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" + + # The TOKEN is replaced with the actual token generated by SPIRE server + # during the test run. + join_token = "TOKEN" +} + +plugins { + NodeAttestor "join_token" { + plugin_data { + } + } + KeyManager "memory" { + plugin_data { + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/join-token/conf/server/server.conf b/test/integration/cassandra-suites/join-token/conf/server/server.conf new file mode 100644 index 0000000000..bc043bfa9b --- /dev/null +++ b/test/integration/cassandra-suites/join-token/conf/server/server.conf @@ -0,0 +1,44 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "join_token" { + plugin_data { + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/join-token/docker-compose.yaml b/test/integration/cassandra-suites/join-token/docker-compose.yaml new file mode 100644 index 0000000000..addd8d922d --- /dev/null +++ b/test/integration/cassandra-suites/join-token/docker-compose.yaml @@ -0,0 +1,24 @@ +services: + spire-server: + image: spire-server:latest-local + env_file: + - ../.env + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + env_file: + - ../.env + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + bad-spire-agent: + image: spire-agent:latest-local + env_file: + - ../.env + volumes: + - ./conf/bad-agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/join-token/teardown b/test/integration/cassandra-suites/join-token/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/join-token/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/k8s/00-setup b/test/integration/cassandra-suites/k8s/00-setup new file mode 100755 index 0000000000..483e77e71a --- /dev/null +++ b/test/integration/cassandra-suites/k8s/00-setup @@ -0,0 +1,24 @@ +#!/bin/bash + +# Create a temporary path that will be added to the PATH to avoid picking up +# binaries from the environment that aren't a version match. +mkdir -p ./bin + +KIND_PATH=./bin/kind +KUBECTL_PATH=./bin/kubectl + +# Download kind at the expected version at the given path. +download-kind "${KIND_PATH}" + +# Download kubectl at the expected version. +download-kubectl "${KUBECTL_PATH}" + +# Start the kind cluster +start-kind-cluster "${KIND_PATH}" k8stest ./conf/kind-config.yaml + +# Load the given images in the cluster. +container_images=("spire-server:latest-local" "spire-agent:latest-local") +load-images "${KIND_PATH}" k8stest "${container_images[@]}" + +# Set the kubectl context. +set-kubectl-context "${KUBECTL_PATH}" kind-k8stest diff --git a/test/integration/cassandra-suites/k8s/01-apply-config b/test/integration/cassandra-suites/k8s/01-apply-config new file mode 100755 index 0000000000..55659a9bca --- /dev/null +++ b/test/integration/cassandra-suites/k8s/01-apply-config @@ -0,0 +1,29 @@ +#!/bin/bash + +source init-kubectl + +wait-for-rollout() { + ns=$1 + obj=$2 + MAXROLLOUTCHECKS=12 + ROLLOUTCHECKINTERVAL=15s + for ((i=0; i<${MAXROLLOUTCHECKS}; i++)); do + log-info "checking rollout status for ${ns} ${obj}..." + if ./bin/kubectl "-n${ns}" rollout status "$obj" --timeout="${ROLLOUTCHECKINTERVAL}"; then + return + fi + log-warn "describing ${ns} ${obj}..." + ./bin/kubectl "-n${ns}" describe "$obj" || true + log-warn "logs for ${ns} ${obj}..." + ./bin/kubectl "-n${ns}" logs --all-containers "$obj" || true + done + fail-now "Failed waiting for ${obj} to roll out." +} + +./bin/kubectl create namespace spire +./bin/kubectl apply -k ./conf/server +wait-for-rollout spire deployment/spire-server +./bin/kubectl apply -k ./conf/agent +wait-for-rollout spire daemonset/spire-agent +./bin/kubectl apply -f ./conf/workload.yaml +wait-for-rollout spire deployment/example-workload diff --git a/test/integration/cassandra-suites/k8s/02-check-for-workload-svid b/test/integration/cassandra-suites/k8s/02-check-for-workload-svid new file mode 100755 index 0000000000..99428b2b2d --- /dev/null +++ b/test/integration/cassandra-suites/k8s/02-check-for-workload-svid @@ -0,0 +1,30 @@ +#!/bin/sh + +source init-kubectl + +NODEUID=$(./bin/kubectl get nodes k8stest-control-plane -o jsonpath='{.metadata.uid}') +./bin/kubectl -nspire exec -t deployment/spire-server -- \ + /opt/spire/bin/spire-server entry create \ + -spiffeID spiffe://example.org/workload \ + -parentID "spiffe://example.org/spire/agent/k8s_psat/example-cluster/${NODEUID}" \ + -selector "k8s:container-name:example-workload" + +MAXFETCHCHECKS=60 +FETCHCHECKINTERVAL=1 +for ((i=1; i<=${MAXFETCHCHECKS}; i++)); do + EXAMPLEPOD=$(./bin/kubectl -nspire get pod -l app=example-workload -o jsonpath="{.items[0].metadata.name}") + log-info "checking for workload SPIFFE ID ($i of $MAXFETCHCHECKS max)..." + if ./bin/kubectl -nspire exec -t "${EXAMPLEPOD}" -- \ + /opt/spire/bin/spire-agent api fetch \ + | grep "SPIFFE ID:"; then + DONE=1 + break + fi + sleep "${FETCHCHECKINTERVAL}" +done + +if [ "${DONE}" -eq 1 ]; then + log-info "SPIFFE ID found." +else + fail-now "timed out waiting for workload to obtain credentials." +fi diff --git a/test/integration/cassandra-suites/k8s/README.md b/test/integration/cassandra-suites/k8s/README.md new file mode 100644 index 0000000000..2145e11828 --- /dev/null +++ b/test/integration/cassandra-suites/k8s/README.md @@ -0,0 +1,9 @@ +# Kubernetes Suite + +## Description + +This suite sets up a Kubernetes cluster using [Kind](https://kind.sigs.k8s.io) and asserts the following: + +* SPIRE server attests SPIRE agents by verifying Kubernetes Projected Service + Account Tokens (i.e. `k8s_psat`) via the Token Review API. +* K8s Workload attestation is successful against a manually registered workload diff --git a/test/integration/cassandra-suites/k8s/conf/agent/kustomization.yaml b/test/integration/cassandra-suites/k8s/conf/agent/kustomization.yaml new file mode 100644 index 0000000000..17d0a0d8e3 --- /dev/null +++ b/test/integration/cassandra-suites/k8s/conf/agent/kustomization.yaml @@ -0,0 +1,10 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# list of Resource Config to be Applied +resources: +- spire-agent.yaml + +# namespace to deploy all Resources to +namespace: spire diff --git a/test/integration/cassandra-suites/k8s/conf/agent/spire-agent.yaml b/test/integration/cassandra-suites/k8s/conf/agent/spire-agent.yaml new file mode 100644 index 0000000000..2cd61562d0 --- /dev/null +++ b/test/integration/cassandra-suites/k8s/conf/agent/spire-agent.yaml @@ -0,0 +1,157 @@ +# ServiceAccount for the SPIRE agent +apiVersion: v1 +kind: ServiceAccount +metadata: + name: spire-agent + namespace: spire + +--- +# Required cluster role to allow spire-agent to query k8s API server +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-agent-cluster-role +rules: + - apiGroups: [""] + resources: ["pods", "nodes", "nodes/proxy"] + verbs: ["get"] + +--- +# Binds above cluster role to spire-agent service account +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-agent-cluster-role-binding +subjects: + - kind: ServiceAccount + name: spire-agent + namespace: spire +roleRef: + kind: ClusterRole + name: spire-agent-cluster-role + apiGroup: rbac.authorization.k8s.io + +--- +# ConfigMap for the SPIRE agent featuring: +# 1) PSAT node attestation +# 2) K8S Workload Attestation over the secure kubelet port +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-agent + namespace: spire +data: + agent.conf: | + agent { + data_dir = "/run/spire" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/run/spire/bundle/bundle.crt" + trust_domain = "example.org" + } + + plugins { + NodeAttestor "k8s_psat" { + plugin_data { + cluster = "example-cluster" + } + } + + KeyManager "memory" { + plugin_data { + } + } + + WorkloadAttestor "k8s" { + plugin_data { + # Defaults to the secure kubelet port by default. + # Minikube does not have a cert in the cluster CA bundle that + # can authenticate the kubelet cert, so skip validation. + skip_kubelet_verification = true + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } + +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: spire-agent + namespace: spire + labels: + app: spire-agent +spec: + selector: + matchLabels: + app: spire-agent + updateStrategy: + type: RollingUpdate + template: + metadata: + namespace: spire + labels: + app: spire-agent + spec: + # hostPID is required for K8S Workload Attestation. + hostPID: true + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + serviceAccountName: spire-agent + containers: + - name: spire-agent + image: spire-agent:latest-local + imagePullPolicy: Never + args: ["-config", "/run/spire/config/agent.conf"] + volumeMounts: + - name: spire-config + mountPath: /run/spire/config + readOnly: true + - name: spire-bundle + mountPath: /run/spire/bundle + readOnly: true + - name: spire-agent-socket + mountPath: /tmp/spire-agent/public + readOnly: false + - name: spire-token + mountPath: /var/run/secrets/tokens + livenessProbe: + httpGet: + path: /live + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: spire-config + configMap: + name: spire-agent + - name: spire-bundle + configMap: + name: spire-bundle + # The volume containing the SPIRE Agent socket that will be used by + # the workload container. + - name: spire-agent-socket + hostPath: + path: /run/spire/agent-sockets + type: DirectoryOrCreate + - name: spire-token + projected: + sources: + - serviceAccountToken: + path: spire-agent + expirationSeconds: 7200 + audience: spire-server diff --git a/test/integration/cassandra-suites/k8s/conf/kind-config.yaml b/test/integration/cassandra-suites/k8s/conf/kind-config.yaml new file mode 100644 index 0000000000..3492b37a45 --- /dev/null +++ b/test/integration/cassandra-suites/k8s/conf/kind-config.yaml @@ -0,0 +1,8 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + image: kindest/node:v1.34.1 + extraMounts: + - hostPath: /usr/local/share/ca-certificates + containerPath: /usr/local/share/ca-certificates diff --git a/test/integration/cassandra-suites/k8s/conf/server/kustomization.yaml b/test/integration/cassandra-suites/k8s/conf/server/kustomization.yaml new file mode 100644 index 0000000000..61ec1abdc4 --- /dev/null +++ b/test/integration/cassandra-suites/k8s/conf/server/kustomization.yaml @@ -0,0 +1,10 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# list of Resource Config to be Applied +resources: +- spire-server.yaml + +# namespace to deploy all Resources to +namespace: spire diff --git a/test/integration/cassandra-suites/k8s/conf/server/spire-server.yaml b/test/integration/cassandra-suites/k8s/conf/server/spire-server.yaml new file mode 100644 index 0000000000..7bed06d664 --- /dev/null +++ b/test/integration/cassandra-suites/k8s/conf/server/spire-server.yaml @@ -0,0 +1,257 @@ +# ServiceAccount used by the SPIRE server. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: spire-server + namespace: spire + +--- + +# Required cluster role to allow spire-server to query k8s API server +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-cluster-role +rules: +- apiGroups: [""] + resources: ["pods", "nodes"] + verbs: ["get", "list", "watch"] + # allow TokenReview requests (to verify service account tokens for PSAT + # attestation) +- apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["get", "create"] + +--- + +# Binds above cluster role to spire-server service account +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-cluster-role-binding + namespace: spire +subjects: +- kind: ServiceAccount + name: spire-server + namespace: spire +roleRef: + kind: ClusterRole + name: spire-server-cluster-role + apiGroup: rbac.authorization.k8s.io + +--- + +# Role for the SPIRE server +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + namespace: spire + name: spire-server-role +rules: + # allow "get" access to pods (to resolve selectors for PSAT attestation) +- apiGroups: [""] + resources: ["pods"] + verbs: ["get"] + # allow access to "get" and "patch" the spire-bundle ConfigMap (for SPIRE + # agent bootstrapping, see the spire-bundle ConfigMap below) +- apiGroups: [""] + resources: ["configmaps"] + resourceNames: ["spire-bundle"] + verbs: ["get", "patch"] +- apiGroups: [""] + resources: ["configmaps"] + verbs: ["create"] +- apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["create", "update", "get"] +- apiGroups: [""] + resources: ["events"] + verbs: ["create"] + +--- + +# RoleBinding granting the spire-server-role to the SPIRE server +# service account. +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-role-binding + namespace: spire +subjects: +- kind: ServiceAccount + name: spire-server + namespace: spire +roleRef: + kind: Role + name: spire-server-role + apiGroup: rbac.authorization.k8s.io + +--- + +# ConfigMap containing the latest trust bundle for the trust domain. It is +# updated by SPIRE using the k8sbundle notifier plugin. SPIRE agents mount +# this config map and use the certificate to bootstrap trust with the SPIRE +# server during attestation. +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-bundle + namespace: spire + +--- + +# ConfigMap containing the SPIRE server configuration. +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + + experimental { + allow_pluggable_datastore = true + } + } + + plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 + + plugin_data { + hosts = ["172.17.0.1:9044", "172.17.0.1:9045", "172.17.0.1:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + + NodeAttestor "k8s_psat" { + plugin_data { + clusters = { + "example-cluster" = { + service_account_allow_list = ["spire:spire-agent"] + } + } + } + } + + KeyManager "disk" { + plugin_data { + keys_path = "/run/spire/data/keys.json" + } + } + + Notifier "k8sbundle" { + plugin_data { + # This plugin updates the bundle.crt value in the spire:spire-bundle + # ConfigMap by default, so no additional configuration is necessary. + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } + +--- + +# This is the Deployment for the SPIRE server. It waits for SPIRE database to +# initialize and uses the SPIRE healthcheck command for liveness/readiness +# probes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spire-server + namespace: spire + labels: + app: spire-server +spec: + replicas: 1 + selector: + matchLabels: + app: spire-server + template: + metadata: + namespace: spire + labels: + app: spire-server + spec: + serviceAccountName: spire-server + shareProcessNamespace: true + containers: + - name: spire-server + image: spire-server:latest-local + imagePullPolicy: Never + args: ["-config", "/run/spire/config/server.conf"] + ports: + - containerPort: 8081 + volumeMounts: + - name: spire-config + mountPath: /run/spire/config + readOnly: true + livenessProbe: + httpGet: + path: /live + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 5 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 5 + volumes: + - name: spire-config + configMap: + name: spire-server + +--- + +# Service definition for SPIRE server defining the gRPC port. +apiVersion: v1 +kind: Service +metadata: + name: spire-server + namespace: spire +spec: + type: NodePort + ports: + - name: grpc + port: 8081 + targetPort: 8081 + protocol: TCP + selector: + app: spire-server diff --git a/test/integration/cassandra-suites/k8s/conf/workload.yaml b/test/integration/cassandra-suites/k8s/conf/workload.yaml new file mode 100644 index 0000000000..db496539bf --- /dev/null +++ b/test/integration/cassandra-suites/k8s/conf/workload.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: example-workload + namespace: spire + labels: + app: example-workload +spec: + selector: + matchLabels: + app: example-workload + template: + metadata: + namespace: spire + labels: + app: example-workload + spire-workload: example-workload + spec: + hostPID: true + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + containers: + - name: example-workload + image: spire-agent:latest-local + command: ["/opt/spire/bin/spire-agent", "api", "watch"] + args: ["-socketPath", "/tmp/spire-agent/public/api.sock"] + volumeMounts: + - name: spire-agent-socket + mountPath: /tmp/spire-agent/public + readOnly: true + volumes: + - name: spire-agent-socket + hostPath: + path: /run/spire/agent-sockets + type: Directory diff --git a/test/integration/cassandra-suites/k8s/init-kubectl b/test/integration/cassandra-suites/k8s/init-kubectl new file mode 100644 index 0000000000..b689f1f417 --- /dev/null +++ b/test/integration/cassandra-suites/k8s/init-kubectl @@ -0,0 +1,8 @@ +#!/bin/bash + +KUBECONFIG="${RUNDIR}/kubeconfig" +if [ ! -f "${RUNDIR}/kubeconfig" ]; then + ./bin/kind get kubeconfig --name=k8stest > "${RUNDIR}/kubeconfig" +fi +export KUBECONFIG + diff --git a/test/integration/cassandra-suites/k8s/integration_k8s_min_version.txt b/test/integration/cassandra-suites/k8s/integration_k8s_min_version.txt new file mode 100644 index 0000000000..f3f644dcf8 --- /dev/null +++ b/test/integration/cassandra-suites/k8s/integration_k8s_min_version.txt @@ -0,0 +1 @@ +v1.31 diff --git a/test/integration/cassandra-suites/k8s/integration_k8s_versions.txt b/test/integration/cassandra-suites/k8s/integration_k8s_versions.txt new file mode 100644 index 0000000000..9a77e546bb --- /dev/null +++ b/test/integration/cassandra-suites/k8s/integration_k8s_versions.txt @@ -0,0 +1 @@ +["v1.31.13","kindest/node:v1.31.12","v0.30.0"] diff --git a/test/integration/cassandra-suites/k8s/teardown b/test/integration/cassandra-suites/k8s/teardown new file mode 100755 index 0000000000..d0c69ac504 --- /dev/null +++ b/test/integration/cassandra-suites/k8s/teardown @@ -0,0 +1,12 @@ +#!/bin/bash + +source init-kubectl + +if [ -z "$SUCCESS" ]; then + ./bin/kubectl -nspire logs deployment/spire-server --all-containers || true + ./bin/kubectl -nspire logs daemonset/spire-agent --all-containers || true + ./bin/kubectl -nspire logs deployment/example-workload --all-containers || true +fi + +export KUBECONFIG= +./bin/kind delete cluster --name k8stest diff --git a/test/integration/cassandra-suites/nested-rotation/00-setup b/test/integration/cassandra-suites/nested-rotation/00-setup new file mode 100755 index 0000000000..b106b29447 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/00-setup @@ -0,0 +1,33 @@ +#!/bin/bash + +# create shared folder for root agent socket +mkdir -p -m 777 shared/rootSocket + +# create shared folder for intermediateA agent socket +mkdir -p -m 777 shared/intermediateASocket + +# create shared folder for intermediateB agent socket +mkdir -p -m 777 shared/intermediateBSocket + +# create shared folder for intermediateA server +mkdir -p -m 777 shared/intermediateA/data + +# create shared folder for intermediateB server +mkdir -p -m 777 shared/intermediateB/data + +# root certificates +"${ROOTDIR}/setup/x509pop/setup.sh" root/server root/agent + +# intermediateA certificates +"${ROOTDIR}/setup/x509pop/setup.sh" intermediateA/server intermediateA/agent + +# leafA certificates +"${ROOTDIR}/setup/x509pop/setup.sh" leafA/server leafA/agent + +# intermediateB certificates +"${ROOTDIR}/setup/x509pop/setup.sh" intermediateB/server intermediateB/agent + +# leafB certificates +"${ROOTDIR}/setup/x509pop/setup.sh" leafB/server leafB/agent + +docker build --target nested-agent-alpine -t nested-agent-alpine . diff --git a/test/integration/cassandra-suites/nested-rotation/01-start-root b/test/integration/cassandra-suites/nested-rotation/01-start-root new file mode 100755 index 0000000000..4b4e9713cd --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/01-start-root @@ -0,0 +1,12 @@ +#!/bin/bash + +log-debug "Starting root-server..." +docker-up root-server +check-server-started "root-server" + +log-debug "bootstrapping root-agent..." +docker compose exec -T root-server \ + /opt/spire/bin/spire-server bundle show > root/agent/bootstrap.crt + +log-debug "Starting root-agent..." +docker-up root-agent diff --git a/test/integration/cassandra-suites/nested-rotation/02-create-intermediate-downstream-entries b/test/integration/cassandra-suites/nested-rotation/02-create-intermediate-downstream-entries new file mode 100755 index 0000000000..3f4b496638 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/02-create-intermediate-downstream-entries @@ -0,0 +1,21 @@ +#!/bin/bash + +log-debug "creating intermediateA downstream registration entry..." +docker compose exec -T root-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint root/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/intermediateA" \ + -selector "docker:label:org.integration.name:intermediateA" \ + -downstream \ + -x509SVIDTTL 3600 +check-synced-entry "root-agent" "spiffe://domain.test/intermediateA" + +log-debug "creating intermediateB downstream registration entry..." +docker compose exec -T root-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint root/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/intermediateB" \ + -selector "docker:label:org.integration.name:intermediateB" \ + -downstream \ + -x509SVIDTTL 3600 +check-synced-entry "root-agent" "spiffe://domain.test/intermediateB" diff --git a/test/integration/cassandra-suites/nested-rotation/03-start-intermediateA b/test/integration/cassandra-suites/nested-rotation/03-start-intermediateA new file mode 100755 index 0000000000..deff493764 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/03-start-intermediateA @@ -0,0 +1,12 @@ +#!/bin/bash + +log-debug "Starting intermediateA-server.." +docker-up intermediateA-server +check-server-started "intermediateA-server" + +log-debug "bootstrapping intermediateA agent..." +docker compose exec -T intermediateA-server \ + /opt/spire/bin/spire-server bundle show > intermediateA/agent/bootstrap.crt + +log-debug "Starting intermediateA-agent..." +docker-up intermediateA-agent diff --git a/test/integration/cassandra-suites/nested-rotation/04-create-leafA-downstream-entry b/test/integration/cassandra-suites/nested-rotation/04-create-leafA-downstream-entry new file mode 100755 index 0000000000..61d0b78b6f --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/04-create-leafA-downstream-entry @@ -0,0 +1,13 @@ +#!/bin/bash + +log-debug "creating leafA downstream registration entry..." +# Create downstream registation entry on intermediateA-server for `leafA-server` +docker compose exec -T intermediateA-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint intermediateA/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/leafA" \ + -selector "docker:label:org.integration.name:leafA" \ + -downstream \ + -x509SVIDTTL 90 + +check-synced-entry "intermediateA-agent" "spiffe://domain.test/leafA" diff --git a/test/integration/cassandra-suites/nested-rotation/05-start-leafA b/test/integration/cassandra-suites/nested-rotation/05-start-leafA new file mode 100755 index 0000000000..838e87202d --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/05-start-leafA @@ -0,0 +1,12 @@ +#!/bin/bash + +log-debug "Starting leafA-server.." +docker-up leafA-server +check-server-started "leafA-server" + +log-debug "bootstrapping leafA agent..." +docker compose exec -T leafA-server \ + /opt/spire/bin/spire-server bundle show > leafA/agent/bootstrap.crt + +log-debug "Starting leafA-agent..." +docker-up leafA-agent diff --git a/test/integration/cassandra-suites/nested-rotation/06-start-intermediateB b/test/integration/cassandra-suites/nested-rotation/06-start-intermediateB new file mode 100755 index 0000000000..ee85af6bd1 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/06-start-intermediateB @@ -0,0 +1,12 @@ +#!/bin/bash + +log-debug "Starting intermediateB-server.." +docker-up intermediateB-server +check-server-started "intermediateB-server" + +log-debug "bootstrapping intermediateB downstream agent..." +docker compose exec -T intermediateB-server \ + /opt/spire/bin/spire-server bundle show > intermediateB/agent/bootstrap.crt + +log-debug "Starting intermediateB-agent..." +docker-up intermediateB-agent diff --git a/test/integration/cassandra-suites/nested-rotation/07-create-leafB-downstream-entry b/test/integration/cassandra-suites/nested-rotation/07-create-leafB-downstream-entry new file mode 100755 index 0000000000..2054bfec05 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/07-create-leafB-downstream-entry @@ -0,0 +1,13 @@ +#!/bin/bash + +log-debug "creating leafB downstream registration entry..." +# Create downstream registration entry on itermediateB for leafB-server +docker compose exec -T intermediateB-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint intermediateB/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/leafB" \ + -selector "docker:label:org.integration.name:leafB" \ + -downstream \ + -x509SVIDTTL 90 + +check-synced-entry "intermediateB-agent" "spiffe://domain.test/leafB" diff --git a/test/integration/cassandra-suites/nested-rotation/08-start-leafB b/test/integration/cassandra-suites/nested-rotation/08-start-leafB new file mode 100755 index 0000000000..61c3326594 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/08-start-leafB @@ -0,0 +1,12 @@ +#!/bin/bash + +log-debug "Starting leafB-server.." +docker-up leafB-server +check-server-started "leafB-server" + +log-debug "bootstrapping leafB agent..." +docker compose exec -T leafB-server \ + /opt/spire/bin/spire-server bundle show > leafB/agent/bootstrap.crt + +log-debug "Starting leafB-agent..." +docker-up leafB-agent diff --git a/test/integration/cassandra-suites/nested-rotation/09-create-workload-entries b/test/integration/cassandra-suites/nested-rotation/09-create-workload-entries new file mode 100755 index 0000000000..c80851e22d --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/09-create-workload-entries @@ -0,0 +1,37 @@ +#!/bin/bash + +log-debug "creating intermediateA workload registration entry..." +docker compose exec -T intermediateA-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint intermediateA/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/intermediateA/workload" \ + -selector "unix:uid:1001" \ + -x509SVIDTTL 0 +check-synced-entry "intermediateA-agent" "spiffe://domain.test/intermediateA/workload" + +log-debug "creating leafA workload registration entry..." +docker compose exec -T leafA-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint leafA/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/leafA/workload" \ + -selector "unix:uid:1001" \ + -x509SVIDTTL 0 +check-synced-entry "leafA-agent" "spiffe://domain.test/leafA/workload" + +log-debug "creating intermediateB workload registration entry..." +docker compose exec -T intermediateB-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint intermediateB/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/intermediateB/workload" \ + -selector "unix:uid:1001" \ + -x509SVIDTTL 0 +check-synced-entry "intermediateB-agent" "spiffe://domain.test/intermediateB/workload" + +log-debug "creating leafB workload registration entry..." +docker compose exec -T leafB-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint leafB/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/leafB/workload" \ + -selector "unix:uid:1001" \ + -x509SVIDTTL 0 +check-synced-entry "leafB-agent" "spiffe://domain.test/leafB/workload" diff --git a/test/integration/cassandra-suites/nested-rotation/10-check-svids b/test/integration/cassandra-suites/nested-rotation/10-check-svids new file mode 100755 index 0000000000..c5a0f3d45d --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/10-check-svids @@ -0,0 +1,62 @@ +#!/bin/bash + +NUMCHECKS=15 +CHECKINTERVAL=6 + +validateX509SVID() { + # Write svid on disk + docker compose exec -u 1001 -T $1 \ + /opt/spire/bin/spire-agent api fetch x509 \ + -socketPath /opt/spire/sockets/workload_api_${1}.sock \ + -write /tmp || fail-now "x509-SVID check failed" + + # Copy SVID + docker cp $(docker compose ps -q $1):/tmp/svid.0.pem - | docker cp - $(docker compose ps -q $2):/opt/ + + docker compose exec -u 1001 -T $2 \ + /opt/spire/bin/spire-agent api fetch x509 \ + -socketPath /opt/spire/sockets/workload_api_${2}.sock \ + -write /tmp || fail-now "x509-SVID check failed" + + docker compose exec -T $2 openssl verify -verbose -CAfile /tmp/bundle.0.pem -untrusted /opt/svid.0.pem /opt/svid.0.pem +} + +validateJWTSVID() { + # Fetch JWT-SVID and extract token + token=$(docker compose exec -u 1001 -T $1 \ + /opt/spire/bin/spire-agent api fetch jwt -audience testIt -socketPath /opt/spire/sockets/workload_api_${1}.sock -output json | jq -r '.[0].svids[0].svid') || fail-now "JWT-SVID check failed" + + # Validate token + docker compose exec -u 1001 -T $2 \ + /opt/spire/bin/spire-agent api validate jwt -audience testIt -svid "${token}" \ + -socketPath /opt/spire/sockets/workload_api_${2}.sock +} + +log-info "checking intermediateB-agent cannot issue JWT since its disabled" +if ! ERROR_OUTPUT=$(docker compose exec -u 1001 -T intermediateB-agent \ + /opt/spire/bin/spire-agent api fetch jwt -audience testIt -socketPath /opt/spire/sockets/workload_api_intermediateB-agent.sock -output json 2>&1); then + + if echo "$ERROR_OUTPUT" | grep -q "code = Unimplemented desc = JWT functionality is disabled"; then + log-info "✓ JWT correctly disabled on intermediateB" + else + fail-now "Expected JWT to be disabled on intermediateB. Got: $ERROR_OUTPUT" + fi +else + fail-now "Expected JWT command to fail but it succeeded" +fi + +for ((i=1;i<=NUMCHECKS;i++)); do + log-info "checking intermediate X509-SVID ($i of $NUMCHECKS)..." + validateX509SVID "intermediateA-agent" "intermediateB-agent" + + log-info "checking leaf X509-SVID ($i of $NUMCHECKS)..." + validateX509SVID "leafA-agent" "leafB-agent" + + log-info "checking intermediate JWT-SVID ($i of $NUMCHECKS)..." + validateJWTSVID "intermediateA-agent" "intermediateB-agent" + + log-info "checking leaf JWT-SVID ($i of $NUMCHECKS)..." + validateJWTSVID "leafA-agent" "leafB-agent" + + sleep "${CHECKINTERVAL}" +done diff --git a/test/integration/cassandra-suites/nested-rotation/11-rotation-after-restart b/test/integration/cassandra-suites/nested-rotation/11-rotation-after-restart new file mode 100755 index 0000000000..0bbd2b1289 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/11-rotation-after-restart @@ -0,0 +1,43 @@ +#!/bin/bash + +check-key-present() { + keyID=${2} + # Check at most 20 times (with one second in between) that the server has + # successfully started. + MAXCHECKS=20 + CHECKINTERVAL=1 + for ((i=1;i<=MAXCHECKS;i++)); do + log-info "checking for bundle to contain key id ${keyID} ($i of $MAXCHECKS max)..." + if docker compose exec -T $1 /opt/spire/bin/spire-server bundle show --format spiffe | grep -q ${keyID}; then + return 0 + fi + sleep "${CHECKINTERVAL}" + done + + fail-now "timed out waiting for key to be present in server bundle" +} + +# Stop leaf servers and agents. This prevents them from publishing keys +# and affecting the test. They rotate CAs/keys much more often so it's +# possible for them to rotate during the test causing the intermediate server +# to also start listening for updates from the upstream server. +docker compose stop leafA-agent leafB-agent leafA-server leafB-server + +log-debug "restarting intermediateB server..." + +# Restart intermediateB server to make sure that it sees updates +# even after restart. The intermediate servers have a longer CA TTL +# so it should allow us to see if upstream authorities fail to propagate +# after restart. +docker compose restart intermediateB-server +check-server-started intermediateB-server + +log-debug "rotating intermediateA JWT authority..." +new_authority_id=$(docker compose exec -T intermediateA-server \ + /opt/spire/bin/spire-server localauthority jwt prepare -output json | jq -r .prepared_authority.authority_id) || fail-now "could not prepare new JWT authority" + +log-debug "activating intermediateA JWT authority..." +docker compose exec -T intermediateA-server \ + /opt/spire/bin/spire-server localauthority jwt activate -authorityID ${new_authority_id} || fail-now "Could not activate new JWT authority" + +check-key-present intermediateB-server ${new_authority_id} diff --git a/test/integration/cassandra-suites/nested-rotation/Dockerfile b/test/integration/cassandra-suites/nested-rotation/Dockerfile new file mode 100644 index 0000000000..d3e3896276 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/Dockerfile @@ -0,0 +1,4 @@ +FROM alpine:3.18 AS nested-agent-alpine +RUN apk add --no-cache --update openssl +COPY --from=spire-agent:latest-local /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent +ENTRYPOINT ["/opt/spire/bin/spire-agent", "run"] diff --git a/test/integration/cassandra-suites/nested-rotation/README.md b/test/integration/cassandra-suites/nested-rotation/README.md new file mode 100644 index 0000000000..1155901f49 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/README.md @@ -0,0 +1,26 @@ +# Nested Rotation Suite + +## Description + +This suite sets a very low TTLs and ensures that workload SVIDs are valid +across many SVID and SPIRE server CA rotation periods using nested servers. +Integration test is configured to work with 3 layers for server/agents: + + root-server + | + root-agent + / \ + intermediateA-server intermediateA-server + | | + intermediateA-agent intermediateA-agent + | | + leafA-server leafA-server + | | + leafA-agent leafA-agent + +Test steps: + +- Fetch an X509-SVID from `intermediateA-agent` and validate it them on `intermediateB-agent` +- Fetch an X509-SVID from `leafA-agent` and validate it on `leafB-agent` +- Fetch a JWT-SVID from `intermediateA-agent` and validate it on `intermediateB-agent` +- Fetch a JWT-SVID from `leafA-agent` and validate it on `leafB-agent` diff --git a/test/integration/cassandra-suites/nested-rotation/docker-compose.yaml b/test/integration/cassandra-suites/nested-rotation/docker-compose.yaml new file mode 100644 index 0000000000..f27042ecd9 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/docker-compose.yaml @@ -0,0 +1,161 @@ +services: + # Root + root-server: + image: spire-server:latest-local + hostname: root-server + env_file: + - ../.env + volumes: + - ./root/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + root-agent: + # Share the host pid namespace so this agent can attest the intermediate servers + pid: "host" + use_api_socket: true + privileged: true + image: spire-agent:latest-local + env_file: + - ../.env + depends_on: ["root-server"] + hostname: root-agent + volumes: + # Share root agent socket to be acceded by leafA and leafB servers + - rootSocket:/opt/spire/sockets:rw + - ./root/agent:/opt/spire/conf/agent + - /var/run/docker.sock:/var/run/docker.sock + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + # IntermediateA + intermediateA-server: + # Share the host pid namespace so this server can be attested by the root agent + pid: "host" + image: spire-server:latest-local + hostname: intermediateA-server + env_file: + - ../.env + labels: + # label to attest server against root-agent + - org.integration.name=intermediateA + depends_on: ["root-server","root-agent"] + volumes: + # Add root agent socket + - rootSocket:/opt/spire/sockets:rw + - ./shared/intermediateA/data:/opt/spire/data/server + - ./intermediateA/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + intermediateA-agent: + # Share the host pid namespace so this agent can attest the leafA server + pid: "host" + use_api_socket: true + privileged: true + env_file: + - ../.env + image: nested-agent-alpine + hostname: intermediateA-agent + depends_on: ["intermediateA-server"] + volumes: + - ./intermediateA/agent:/opt/spire/conf/agent + # Share intermediateA agent socket to be acceded by leafA server + - intermediateASocket:/opt/spire/sockets:rw + - /var/run/docker.sock:/var/run/docker.sock + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + # LeafA + leafA-server: + # Share the host pid namespace so this server can be attested by the intermediateA agent + pid: "host" + image: spire-server:latest-local + hostname: leafA-server + env_file: + - ../.env + labels: + # Label to attest server against intermediateA-agent + - org.integration.name=leafA + depends_on: ["intermediateA-server","intermediateA-agent"] + volumes: + # Add intermediatA agent socket + - intermediateASocket:/opt/spire/sockets:rw + - ./leafA/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + leafA-agent: + image: nested-agent-alpine + hostname: leafA-agent + env_file: + - ../.env + depends_on: ["intermediateA-server"] + volumes: + - ./leafA/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + # IntermediateB + intermediateB-server: + # Share the host pid namespace so this server can be attested by the root agent + pid: "host" + image: spire-server:latest-local + hostname: intermediateB-server + env_file: + - ../.env + depends_on: ["root-server","root-agent"] + labels: + # Label to attest server against root-agent + - org.integration.name=intermediateB + volumes: + # Add root agent socket + - rootSocket:/opt/spire/sockets:rw + - ./shared/intermediateB/data:/opt/spire/data/server + - ./intermediateB/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + intermediateB-agent: + # Share the host pid namespace so this agent can attest the leafB server + pid: "host" + use_api_socket: true + env_file: + - ../.env + privileged: true + image: nested-agent-alpine + hostname: intermediateB-agent + depends_on: ["intermediateB-server"] + volumes: + - ./intermediateB/agent:/opt/spire/conf/agent + # Share intermediateB agent socket to be acceded by leafB server + - intermediateBSocket:/opt/spire/sockets:rw + - /var/run/docker.sock:/var/run/docker.sock + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + # leafB + leafB-server: + # Share the host pid namespace so this server can be attested by the intermediateB agent + pid: "host" + image: spire-server:latest-local + env_file: + - ../.env + hostname: leafB-server + depends_on: ["intermediateB-server","intermediateB-agent"] + labels: + # Label to attest server against intermediateB-agent + - org.integration.name=leafB + volumes: + # Add intermediateB agent socket + - intermediateBSocket:/opt/spire/sockets:rw + - ./leafB/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + leafB-agent: + image: nested-agent-alpine + env_file: + - ../.env + hostname: leafB-agent + depends_on: ["leafB-server"] + volumes: + - ./leafB/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + +volumes: + intermediateASocket: + intermediateBSocket: + rootSocket: \ No newline at end of file diff --git a/test/integration/cassandra-suites/nested-rotation/intermediateA/agent/agent.conf b/test/integration/cassandra-suites/nested-rotation/intermediateA/agent/agent.conf new file mode 100644 index 0000000000..e93a9b47fd --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/intermediateA/agent/agent.conf @@ -0,0 +1,31 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "intermediateA-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api_intermediateA-agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } + WorkloadAttestor "docker" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/nested-rotation/intermediateA/server/server.conf b/test/integration/cassandra-suites/nested-rotation/intermediateA/server/server.conf new file mode 100644 index 0000000000..b92d9b4527 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/intermediateA/server/server.conf @@ -0,0 +1,35 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "15s" +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/opt/spire/data/server/datastore.sqlite3" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "disk" { + plugin_data = { + keys_path = "/opt/spire/data/server/keys.json" + } + } + UpstreamAuthority "spire" { + plugin_data = { + server_address = "root-server" + server_port = 8081 + workload_api_socket = "/opt/spire/sockets/workload_api_root-agent.sock" + } + } +} diff --git a/test/integration/cassandra-suites/nested-rotation/intermediateB/agent/agent.conf b/test/integration/cassandra-suites/nested-rotation/intermediateB/agent/agent.conf new file mode 100644 index 0000000000..56a6b0e6d4 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/intermediateB/agent/agent.conf @@ -0,0 +1,31 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "intermediateB-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api_intermediateB-agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } + WorkloadAttestor "docker" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/nested-rotation/intermediateB/server/server.conf b/test/integration/cassandra-suites/nested-rotation/intermediateB/server/server.conf new file mode 100644 index 0000000000..84a9942de1 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/intermediateB/server/server.conf @@ -0,0 +1,36 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "15s" + disable_jwt_svids = true +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/opt/spire/data/server/datastore.sqlite3" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "disk" { + plugin_data = { + keys_path = "/opt/spire/data/server/keys.json" + } + } + UpstreamAuthority "spire" { + plugin_data = { + server_address = "root-server" + server_port = 8081 + workload_api_socket = "/opt/spire/sockets/workload_api_root-agent.sock" + } + } +} diff --git a/test/integration/cassandra-suites/nested-rotation/leafA/agent/agent.conf b/test/integration/cassandra-suites/nested-rotation/leafA/agent/agent.conf new file mode 100644 index 0000000000..3479eced12 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/leafA/agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "leafA-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api_leafA-agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/nested-rotation/leafA/server/server.conf b/test/integration/cassandra-suites/nested-rotation/leafA/server/server.conf new file mode 100644 index 0000000000..1baa93abba --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/leafA/server/server.conf @@ -0,0 +1,33 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "90s" + default_x509_svid_ttl = "15s" +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/opt/spire/data/server/datastore.sqlite3" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } + UpstreamAuthority "spire" { + plugin_data = { + server_address = "intermediateA-server" + server_port = 8081 + workload_api_socket = "/opt/spire/sockets/workload_api_intermediateA-agent.sock" + } + } +} diff --git a/test/integration/cassandra-suites/nested-rotation/leafB/agent/agent.conf b/test/integration/cassandra-suites/nested-rotation/leafB/agent/agent.conf new file mode 100644 index 0000000000..fd08e41ef6 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/leafB/agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "leafB-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api_leafB-agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/nested-rotation/leafB/server/server.conf b/test/integration/cassandra-suites/nested-rotation/leafB/server/server.conf new file mode 100644 index 0000000000..bc54fbb802 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/leafB/server/server.conf @@ -0,0 +1,33 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "90s" + default_x509_svid_ttl = "15s" +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/opt/spire/data/server/datastore.sqlite3" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } + UpstreamAuthority "spire" { + plugin_data = { + server_address = "intermediateB-server" + server_port = 8081 + workload_api_socket = "/opt/spire/sockets/workload_api_intermediateB-agent.sock" + } + } +} diff --git a/test/integration/cassandra-suites/nested-rotation/root/agent/agent.conf b/test/integration/cassandra-suites/nested-rotation/root/agent/agent.conf new file mode 100644 index 0000000000..33e77828e8 --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/root/agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "root-server" + server_port = "8081" + socket_path ="/opt/spire/sockets/workload_api_root-agent.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "docker" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/nested-rotation/root/server/server.conf b/test/integration/cassandra-suites/nested-rotation/root/server/server.conf new file mode 100644 index 0000000000..0d4fd5722a --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/root/server/server.conf @@ -0,0 +1,48 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "15s" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/nested-rotation/teardown b/test/integration/cassandra-suites/nested-rotation/teardown new file mode 100755 index 0000000000..f28d5eaffd --- /dev/null +++ b/test/integration/cassandra-suites/nested-rotation/teardown @@ -0,0 +1,7 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi + +docker-down diff --git a/test/integration/cassandra-suites/node-attestation/00-setup b/test/integration/cassandra-suites/node-attestation/00-setup new file mode 100755 index 0000000000..9bb3aae582 --- /dev/null +++ b/test/integration/cassandra-suites/node-attestation/00-setup @@ -0,0 +1,11 @@ +#!/bin/bash +echo ${ROOTDIR} + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent conf/ +# Move test x509pop certificate and key +mv conf/agent.key.pem conf/agent/test.key.pem +mv conf/agent.crt.pem conf/agent/test.crt.pem + +"${ROOTDIR}/setup/node-attestation/build.sh" "${RUNDIR}/conf/server/node-attestation" +"${ROOTDIR}/setup/node-attestation/build.sh" "${RUNDIR}/conf/agent/node-attestation" + diff --git a/test/integration/cassandra-suites/node-attestation/01-start-server b/test/integration/cassandra-suites/node-attestation/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/node-attestation/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/node-attestation/02-start-agent b/test/integration/cassandra-suites/node-attestation/02-start-agent new file mode 100755 index 0000000000..fc5ae5816a --- /dev/null +++ b/test/integration/cassandra-suites/node-attestation/02-start-agent @@ -0,0 +1,8 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt + +log-debug "starting agent..." +docker compose up -d "spire-agent" || fail-now "failed to bring up services." diff --git a/test/integration/cassandra-suites/node-attestation/03-test-node-attestation b/test/integration/cassandra-suites/node-attestation/03-test-node-attestation new file mode 100755 index 0000000000..2236027a1d --- /dev/null +++ b/test/integration/cassandra-suites/node-attestation/03-test-node-attestation @@ -0,0 +1,31 @@ +#!/bin/bash + +# Test node attestation api +jointoken=`docker compose exec -u 1000 -T spire-server /opt/spire/conf/server/node-attestation -testStep jointoken` +echo "Created Join Token" $jointoken + +svid1=`docker compose exec -u 1000 -T spire-agent /opt/spire/conf/agent/node-attestation -testStep jointokenattest -tokenName $jointoken` +if [[ $? -ne 0 ]]; +then + fail-now "Failed to do initial join token attestation" +fi +echo "Received initial SVID:" $svid1 + +svid2=`docker compose exec -u 1000 -T spire-agent /opt/spire/conf/agent/node-attestation -testStep renew -certificate "${svid1}"` +if [[ $? -ne 0 ]]; +then + fail-now "Failed to do SVID renewal" +fi +echo "Received renewed SVID:" $svid2 + +docker compose exec -u 1000 -T spire-server /opt/spire/conf/server/node-attestation -testStep ban -tokenName ${jointoken} +if [[ $? -ne 0 ]]; +then + fail-now "Failed to do initial join token attestation" +fi +echo "Agent banned" + +if docker compose exec -u 1000 -T spire-server /opt/spire/conf/server/node-attestation -testStep renew -certificate "${svid2}" +then + fail-now "Expected agent to be banned" +fi diff --git a/test/integration/cassandra-suites/node-attestation/04-test-x509pop-attestation b/test/integration/cassandra-suites/node-attestation/04-test-x509pop-attestation new file mode 100755 index 0000000000..79ad304327 --- /dev/null +++ b/test/integration/cassandra-suites/node-attestation/04-test-x509pop-attestation @@ -0,0 +1,14 @@ +#!/bin/bash + +log-debug "creating admin registration entry..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/admin" \ + -selector "unix:uid:1000" \ + -admin \ + -x509SVIDTTL 0 +check-synced-entry "spire-agent" "spiffe://domain.test/admin" + +log-debug "running x509pop test..." +docker compose exec -u 1000 -T spire-agent /opt/spire/conf/agent/node-attestation -testStep x509pop || fail-now "failed to check x509pop attestion" diff --git a/test/integration/cassandra-suites/node-attestation/README.md b/test/integration/cassandra-suites/node-attestation/README.md new file mode 100644 index 0000000000..d56982ee88 --- /dev/null +++ b/test/integration/cassandra-suites/node-attestation/README.md @@ -0,0 +1,6 @@ +# Node Attestation Suite + +## Description + +Basic tests of the node attestation APIs using a simple fake agent +The agent runs in a separate Docker container, but nothing from the real SPIRE agent is used diff --git a/test/integration/cassandra-suites/node-attestation/conf/agent/agent.conf b/test/integration/cassandra-suites/node-attestation/conf/agent/agent.conf new file mode 100644 index 0000000000..f79c4e9b06 --- /dev/null +++ b/test/integration/cassandra-suites/node-attestation/conf/agent/agent.conf @@ -0,0 +1,26 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/node-attestation/conf/server/server.conf b/test/integration/cassandra-suites/node-attestation/conf/server/server.conf new file mode 100644 index 0000000000..2b324efd99 --- /dev/null +++ b/test/integration/cassandra-suites/node-attestation/conf/server/server.conf @@ -0,0 +1,48 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/node-attestation/docker-compose.yaml b/test/integration/cassandra-suites/node-attestation/docker-compose.yaml new file mode 100644 index 0000000000..d8c2241661 --- /dev/null +++ b/test/integration/cassandra-suites/node-attestation/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + spire-server: + image: spire-server:latest-local + env_file: + - ../.env + hostname: spire-server + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/node-attestation/teardown b/test/integration/cassandra-suites/node-attestation/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/node-attestation/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/node-re-attestation/00-setup b/test/integration/cassandra-suites/node-re-attestation/00-setup new file mode 100755 index 0000000000..ba41bdce61 --- /dev/null +++ b/test/integration/cassandra-suites/node-re-attestation/00-setup @@ -0,0 +1,5 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent conf/ +"${ROOTDIR}/setup/node-attestation/build.sh" "${RUNDIR}/conf/server/node-attestation" +"${ROOTDIR}/setup/node-attestation/build.sh" "${RUNDIR}/conf/agent/node-attestation" diff --git a/test/integration/cassandra-suites/node-re-attestation/01-start-server b/test/integration/cassandra-suites/node-re-attestation/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/node-re-attestation/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/node-re-attestation/02-start-agent b/test/integration/cassandra-suites/node-re-attestation/02-start-agent new file mode 100755 index 0000000000..c6f98a4e52 --- /dev/null +++ b/test/integration/cassandra-suites/node-re-attestation/02-start-agent @@ -0,0 +1,25 @@ +#!/bin/bash +source ./common + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt + +log-info "generating join token..." +TOKEN=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server token generate -spiffeID spiffe://domain.test/node -output json | jq -r ".value") + +# Inserts the join token into the agent configuration +log-debug "using join token ${TOKEN}..." +sed -i.bak "s#TOKEN#${TOKEN}#g" conf/agent/agent_jointoken.conf + +log-debug "starting agent a..." +docker compose up -d "spire-agent-a" || fail-now "failed to bring up services." + +log-debug "starting agent b..." +docker compose up -d "spire-agent-b" || fail-now "failed to bring up services." + +AGENT_A_SPIFFE_ID_PATH="/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" +AGENT_B_SPIFFE_ID_PATH="/spire/agent/join_token/$(ggrep -oP '(?<=join_token = ")[^"]*' conf/agent/agent_jointoken.conf)" + +check-attested-agents $AGENT_A_SPIFFE_ID_PATH $AGENT_B_SPIFFE_ID_PATH diff --git a/test/integration/cassandra-suites/node-re-attestation/03-evict-agents b/test/integration/cassandra-suites/node-re-attestation/03-evict-agents new file mode 100755 index 0000000000..04db87fe75 --- /dev/null +++ b/test/integration/cassandra-suites/node-re-attestation/03-evict-agents @@ -0,0 +1,18 @@ +#!/bin/bash +source ./common + +AGENT_A_SPIFFE_ID="spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" +AGENT_B_SPIFFE_ID="spiffe://domain.test/spire/agent/join_token/$(ggrep -oP '(?<=join_token = ")[^"]*' conf/agent/agent_jointoken.conf)" + +log-debug "evicting agents..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent evict -spiffeID $AGENT_A_SPIFFE_ID || fail-now "failed to evict agent a." + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent evict -spiffeID $AGENT_B_SPIFFE_ID || fail-now "failed to evict agent b." + +check-evict-agents $AGENT_A_SPIFFE_ID $AGENT_B_SPIFFE_ID + +# spire-agent-a will re-attest but spire-agent-b won't because join_token implements trust on first use model. +AGENT_A_SPIFFE_ID_PATH="/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" +check-attested-agents $AGENT_A_SPIFFE_ID_PATH diff --git a/test/integration/cassandra-suites/node-re-attestation/04-check-re-attest b/test/integration/cassandra-suites/node-re-attestation/04-check-re-attest new file mode 100755 index 0000000000..96200cf943 --- /dev/null +++ b/test/integration/cassandra-suites/node-re-attestation/04-check-re-attest @@ -0,0 +1,9 @@ +#!/bin/bash +source ./common + +docker compose restart "spire-agent-a" "spire-agent-b" || fail-now "failed to stop services." + +# spire-agent-b can't re-attest because join_token implements trust on first use model. +AGENT_A_SPIFFE_ID_PATH="/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" + +check-attested-agents $AGENT_A_SPIFFE_ID_PATH diff --git a/test/integration/cassandra-suites/node-re-attestation/README.md b/test/integration/cassandra-suites/node-re-attestation/README.md new file mode 100644 index 0000000000..509b9aa6a1 --- /dev/null +++ b/test/integration/cassandra-suites/node-re-attestation/README.md @@ -0,0 +1,10 @@ +# Node Re-Attestation Suite + +## Description + +This suite tests the node re-attestation flow. It starts two spire agents, then evicts them to force the re-attestation flow. + +Here we will use two spire agents: + +- spire agent A is configured with the x509pop plugin, that allows the node re-attestation. +- spire agent B is configured with the join token plugin, with implements the TOFU security model and don't allow the node re-attestation. diff --git a/test/integration/cassandra-suites/node-re-attestation/common b/test/integration/cassandra-suites/node-re-attestation/common new file mode 100644 index 0000000000..eec629bd0a --- /dev/null +++ b/test/integration/cassandra-suites/node-re-attestation/common @@ -0,0 +1,50 @@ +#!/bin/bash + +check-attested-agents () { + EXPECTED_COUNT=$# + MAXCHECKS=10 + CHECKINTERVAL=1 + + for ((i=1;i<=MAXCHECKS;i++)); do + log-debug "checking attested agents ($i of $MAXCHECKS max)......" + MATCHING_COUNT=0 + AGENTS=$(docker compose exec -T spire-server /opt/spire/bin/spire-server agent list -output json) + AGENTS_COUNT=$(jq -r '.agents | length' <<< "$AGENTS") + + for spiffe_id_path in "$@"; do + if jq -e --arg spiffe_id_path "$spiffe_id_path" '.agents[] | select(.id.path == $spiffe_id_path)' <<< "$AGENTS" > /dev/null; then + MATCHING_COUNT=$((MATCHING_COUNT+1)) + fi + done + + if [[ $MATCHING_COUNT = $EXPECTED_COUNT && $MATCHING_COUNT = $AGENTS_COUNT ]]; then + return 0 + fi + sleep "${CHECKINTERVAL}" + done + + fail-now "Expected $EXPECTED_COUNT agents to be attested, found $MATCHING_COUNT matches out of $AGENTS_COUNT agents" +} + +check-evict-agents() { + MAXCHECKS=10 + CHECKINTERVAL=1 + EXPECTED_COUNT=$# + for ((i=1;i<=MAXCHECKS;i++)); do + MATCHING_COUNT=0 + log-info "checking for evicted agent ($i of $MAXCHECKS max)..." + for spiffe_id in "$@"; do + if docker compose logs "spire-server" | grep "Agent is not attested" | grep "caller_id=\"$spiffe_id\""; then + MATCHING_COUNT=$((MATCHING_COUNT+1)) + fi + done + + if [[ $MATCHING_COUNT = $EXPECTED_COUNT ]]; then + return 0 + fi + + sleep "${CHECKINTERVAL}" + done + + fail-now "timed out waiting for agent to be evicted" +} diff --git a/test/integration/cassandra-suites/node-re-attestation/conf/agent/agent_jointoken.conf b/test/integration/cassandra-suites/node-re-attestation/conf/agent/agent_jointoken.conf new file mode 100644 index 0000000000..68b40d5c8a --- /dev/null +++ b/test/integration/cassandra-suites/node-re-attestation/conf/agent/agent_jointoken.conf @@ -0,0 +1,28 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" + + # The token is replaced with the actual token generated by SPIRE server + # during the test run. + join_token = "TOKEN" +} + +plugins { + NodeAttestor "join_token" { + plugin_data { + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/node-re-attestation/conf/agent/agent_x509pop.conf b/test/integration/cassandra-suites/node-re-attestation/conf/agent/agent_x509pop.conf new file mode 100644 index 0000000000..f79c4e9b06 --- /dev/null +++ b/test/integration/cassandra-suites/node-re-attestation/conf/agent/agent_x509pop.conf @@ -0,0 +1,26 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/node-re-attestation/conf/server/server.conf b/test/integration/cassandra-suites/node-re-attestation/conf/server/server.conf new file mode 100644 index 0000000000..2b324efd99 --- /dev/null +++ b/test/integration/cassandra-suites/node-re-attestation/conf/server/server.conf @@ -0,0 +1,48 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/node-re-attestation/docker-compose.yaml b/test/integration/cassandra-suites/node-re-attestation/docker-compose.yaml new file mode 100644 index 0000000000..3ab54c29aa --- /dev/null +++ b/test/integration/cassandra-suites/node-re-attestation/docker-compose.yaml @@ -0,0 +1,29 @@ +services: + spire-server: + image: spire-server:latest-local + env_file: + - ../.env + hostname: spire-server + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent-a: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent_x509pop.conf"] + spire-agent-b: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent + depends_on: [ "spire-server" ] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: [ "-config", "/opt/spire/conf/agent/agent_jointoken.conf" ] diff --git a/test/integration/cassandra-suites/node-re-attestation/teardown b/test/integration/cassandra-suites/node-re-attestation/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/node-re-attestation/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/00-setup b/test/integration/cassandra-suites/oidc-discovery-provider/00-setup new file mode 100755 index 0000000000..c1fb18218e --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/00-setup @@ -0,0 +1,6 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent + +"${ROOTDIR}/setup/debugserver/build.sh" "${RUNDIR}/conf/server/debugclient" +"${ROOTDIR}/setup/debugagent/build.sh" "${RUNDIR}/conf/agent/debugclient" diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/01-start-server b/test/integration/cassandra-suites/oidc-discovery-provider/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/02-bootstrap-agent b/test/integration/cassandra-suites/oidc-discovery-provider/02-bootstrap-agent new file mode 100755 index 0000000000..27a2eca7f6 --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/02-bootstrap-agent @@ -0,0 +1,5 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show -socketPath /opt/spire/conf/server/api.sock >conf/agent/bootstrap.crt diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/03-assert-jwks-using-server-api b/test/integration/cassandra-suites/oidc-discovery-provider/03-assert-jwks-using-server-api new file mode 100755 index 0000000000..467f3ced8d --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/03-assert-jwks-using-server-api @@ -0,0 +1,9 @@ +#!/bin/bash + +source common + +docker-up oidc-discovery-provider-server + +check-provider-start ${RUNDIR}/conf/oidc-discovery-provider/provider-server.sock + +check-equal-keys ${RUNDIR}/conf/oidc-discovery-provider/provider-server.sock diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/04-assert-jwks-using-workload-api b/test/integration/cassandra-suites/oidc-discovery-provider/04-assert-jwks-using-workload-api new file mode 100755 index 0000000000..64953a7a80 --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/04-assert-jwks-using-workload-api @@ -0,0 +1,22 @@ +#!/bin/bash + +source common + +docker-up spire-agent + +log-debug "creating registration entry for oidc-provider" +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create -socketPath /opt/spire/conf/server/api.sock \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/oidc-provider" \ + -selector "docker:label:org.integration.name:oidc-discovery-provider" \ + -x509SVIDTTL 0 \ + -jwtSVIDTTL 0 + +check-synced-entry "spire-agent" "spiffe://domain.test/oidc-provider" + +docker-up oidc-discovery-provider-workload + +check-provider-start ${RUNDIR}/conf/oidc-discovery-provider/provider-workload.sock + +check-equal-keys ${RUNDIR}/conf/oidc-discovery-provider/provider-workload.sock diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/README.md b/test/integration/cassandra-suites/oidc-discovery-provider/README.md new file mode 100644 index 0000000000..343ad64bf7 --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/README.md @@ -0,0 +1,7 @@ +# Fetch x509-SVID Suite + +## Description + +This suite validates the OIDC discovery provider component. It starts spire server, spire agent and oidc discovery provider. +In this suite, the oidc discovery provider is first configured to fetch the JWKS from spire server API, them from the spire agent +workload API. This suite only test OIDC discovery provider using unix domain socket, ACME and Serving Certs configurations are not tested. diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/common b/test/integration/cassandra-suites/oidc-discovery-provider/common new file mode 100644 index 0000000000..5938db2dd2 --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/common @@ -0,0 +1,59 @@ +#!/bin/bash + +check-equal-keys() { + PROVIDER_SOCKET_PATH=$1 + + JWK=$(curl --unix-socket $PROVIDER_SOCKET_PATH http://localhost/keys | jq ".keys[0]" || fail-now "Failed to fetch JWK from provider") + BUNDLE=$(docker compose exec -T spire-server /opt/spire/bin/spire-server bundle show -socketPath /opt/spire/conf/server/api.sock -output json | jq ".jwt_authorities[0]" || fail-now "Failed to fetch JWT bundle from SPIRE server") + + PROVIDER_KEY_ID=$(echo ${JWK} | jq -r ".kid") + BUNDLE_KEY_ID=$(echo ${BUNDLE} | jq -r ".key_id") + + if [ "${PROVIDER_KEY_ID}" != "${BUNDLE_KEY_ID}" ]; then + fail-now "JWK key id (${PROVIDER_KEY_ID}) does not match bundle key id (${BUNDLE_KEY_ID})" + fi + + OIDC_CONFIG=$(curl --unix-socket ${RUNDIR}/conf/oidc-discovery-provider/provider-server.sock http://localhost/.well-known/openid-configuration | jq) + + EXPECTED_OIDC_CONFIG=$(jq <./expected-oidc-config.json) + + if [ "${OIDC_CONFIG}" != "${EXPECTED_OIDC_CONFIG}" ]; then + echo "OIDC_CONFIG: ${OIDC_CONFIG}" + echo "EXPECTED_OIDC_CONFIG: ${EXPECTED_OIDC_CONFIG}" + fail-now "OIDC config does not match expected" + fi + + BUNDLE_PK=$(echo ${BUNDLE} | jq -r ".public_key") + + JWK_KEY=$(echo ${JWK} | jq -r '.x + .y') + + DER_KEY=$(echo "$BUNDLE_PK" | base64 -d | openssl ec -pubin -inform DER -text -noout | grep -E "[0-9a-fA-F]{2}:" | tr -d '[:space:]' | cut -c4-) + + FIRST_HALF=$(echo "$DER_KEY" | cut -c1-$((${#DER_KEY} / 2)) | xxd -r -p | base64) + + SECOND_HALF=$(echo "$DER_KEY" | cut -c$((${#DER_KEY} / 2 + 1))- | cut -c2- | xxd -r -p | base64) + + DER_KEY=$(echo "$FIRST_HALF$SECOND_HALF" | tr -d '=') + # convert JWK_KEY from base64url to base64 + JWK_KEY=$(echo "$JWK_KEY" | tr '_-' '/+' | tr -d '=') + + if [ "$DER_KEY" != "$JWK_KEY" ]; then + fail-now "JWK key does not match bundle key: $DER_KEY != $JWK_KEY" + fi +} + +check-provider-start() { + MAXCHECKS=10 + CHECKINTERVAL=1 + PROVIDER_SOCKET_PATH=$1 + + for ((i = 1; i <= MAXCHECKS; i++)); do + log-info "check oidc-discovery-provider status ($(($i)) of $MAXCHECKS max)..." + curl --unix-socket $PROVIDER_SOCKET_PATH http://localhost && return 0 + sleep "${CHECKINTERVAL}" + done + + if (($i > $MAXCHECKS)); then + fail-now "timed out waiting for oidc-discovery-provider to start" + fi +} diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/conf/agent/agent.conf b/test/integration/cassandra-suites/oidc-discovery-provider/conf/agent/agent.conf new file mode 100644 index 0000000000..a7cfc3dc67 --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/conf/agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + socket_path = "/opt/spire/conf/agent/workload_api.sock" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "docker" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/conf/oidc-discovery-provider/provider-server-api.conf b/test/integration/cassandra-suites/oidc-discovery-provider/conf/oidc-discovery-provider/provider-server-api.conf new file mode 100644 index 0000000000..292c9ba37c --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/conf/oidc-discovery-provider/provider-server-api.conf @@ -0,0 +1,6 @@ +log_level = "DEBUG" +domains = ["localhost"] +listen_socket_path = "/opt/spire/conf/oidc-discovery-provider/provider-server.sock" +server_api { + address = "unix:///opt/spire/conf/server/api.sock" +} diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/conf/oidc-discovery-provider/provider-workload-api.conf b/test/integration/cassandra-suites/oidc-discovery-provider/conf/oidc-discovery-provider/provider-workload-api.conf new file mode 100644 index 0000000000..ad37f91371 --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/conf/oidc-discovery-provider/provider-workload-api.conf @@ -0,0 +1,7 @@ +log_level = "DEBUG" +domains = ["localhost"] +listen_socket_path = "/opt/spire/conf/oidc-discovery-provider/provider-workload.sock" +workload_api { + socket_path = "/opt/spire/conf/agent/workload_api.sock" + trust_domain = "domain.test" +} diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/conf/server/server.conf b/test/integration/cassandra-suites/oidc-discovery-provider/conf/server/server.conf new file mode 100644 index 0000000000..06cf3e7333 --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/conf/server/server.conf @@ -0,0 +1,49 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + socket_path = "/opt/spire/conf/server/api.sock" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/docker-compose.yaml b/test/integration/cassandra-suites/oidc-discovery-provider/docker-compose.yaml new file mode 100644 index 0000000000..d296389ce4 --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/docker-compose.yaml @@ -0,0 +1,51 @@ +services: + spire-server: + image: spire-server:latest-local + env_file: + - ../.env + hostname: spire-server + volumes: + - ./conf/server:/opt/spire/conf/server + command: [ "-config", "/opt/spire/conf/server/server.conf" ] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + pid: "host" + env_file: + - ../.env + image: spire-agent:latest-local + hostname: spire-agent + depends_on: [ "spire-server" ] + volumes: + - ./conf/agent:/opt/spire/conf/agent + - /var/run/docker.sock:/var/run/docker.sock + command: [ "-config", "/opt/spire/conf/agent/agent.conf" ] + user: 0:0 # Required to access the Docker daemon socket + oidc-discovery-provider-server: + image: oidc-discovery-provider:latest-local + env_file: + - ../.env + hostname: oidc-discovery-provider-server + depends_on: [ "spire-server" ] + volumes: + - ./conf/oidc-discovery-provider:/opt/spire/conf/oidc-discovery-provider + - ./conf/agent:/opt/spire/conf/agent + - ./conf/server:/opt/spire/conf/server + command: [ "-config", "/opt/spire/conf/oidc-discovery-provider/provider-server-api.conf" ] + user: 0:0 # Required to access the Docker daemon socket + oidc-discovery-provider-workload: + pid: "host" + image: oidc-discovery-provider:latest-local + hostname: oidc-discovery-provider-server + env_file: + - ../.env + depends_on: [ "spire-server" ] + labels: + # label to attest oidc against agent + - org.integration.name=oidc-discovery-provider + volumes: + - ./conf/oidc-discovery-provider:/opt/spire/conf/oidc-discovery-provider + - ./conf/agent:/opt/spire/conf/agent + - ./conf/server:/opt/spire/conf/server + command: [ "-config", "/opt/spire/conf/oidc-discovery-provider/provider-workload-api.conf" ] + user: 0:0 # Required to access the Docker daemon socket diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/expected-oidc-config.json b/test/integration/cassandra-suites/oidc-discovery-provider/expected-oidc-config.json new file mode 100644 index 0000000000..cb642e9ee4 --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/expected-oidc-config.json @@ -0,0 +1,14 @@ +{ + "issuer": "https://localhost", + "jwks_uri": "https://localhost/keys", + "authorization_endpoint": "", + "response_types_supported": [ + "id_token" + ], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": [ + "RS256", + "ES256", + "ES384" + ] +} diff --git a/test/integration/cassandra-suites/oidc-discovery-provider/teardown b/test/integration/cassandra-suites/oidc-discovery-provider/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/oidc-discovery-provider/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/rotation/00-setup b/test/integration/cassandra-suites/rotation/00-setup new file mode 100755 index 0000000000..49c69db23c --- /dev/null +++ b/test/integration/cassandra-suites/rotation/00-setup @@ -0,0 +1,3 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent diff --git a/test/integration/cassandra-suites/rotation/01-start-server b/test/integration/cassandra-suites/rotation/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/rotation/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/rotation/02-bootstrap-agent b/test/integration/cassandra-suites/rotation/02-bootstrap-agent new file mode 100755 index 0000000000..8ee7d32c26 --- /dev/null +++ b/test/integration/cassandra-suites/rotation/02-bootstrap-agent @@ -0,0 +1,5 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt diff --git a/test/integration/cassandra-suites/rotation/03-start-agent b/test/integration/cassandra-suites/rotation/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/rotation/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/rotation/04-create-workload-entry b/test/integration/cassandra-suites/rotation/04-create-workload-entry new file mode 100755 index 0000000000..31e36c8c66 --- /dev/null +++ b/test/integration/cassandra-suites/rotation/04-create-workload-entry @@ -0,0 +1,24 @@ +#!/bin/bash + +log-debug "creating registration entry..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload" \ + -selector "unix:uid:0" \ + -x509SVIDTTL 0 + +# Check at most 30 times (with one second in between) that the agent has +# successfully synced down the workload entry. +MAXCHECKS=30 +CHECKINTERVAL=1 +for ((i=1;i<=MAXCHECKS;i++)); do + log-info "checking for synced workload entry ($i of $MAXCHECKS max)..." + docker compose logs spire-agent + if docker compose logs spire-agent | grep "spiffe://domain.test/workload"; then + exit 0 + fi + sleep "${CHECKINTERVAL}" +done + +fail-now "timed out waiting for agent to sync down entry" diff --git a/test/integration/cassandra-suites/rotation/05-check-svids b/test/integration/cassandra-suites/rotation/05-check-svids new file mode 100755 index 0000000000..3c04e58fb7 --- /dev/null +++ b/test/integration/cassandra-suites/rotation/05-check-svids @@ -0,0 +1,13 @@ +#!/bin/bash + +# 45 seconds should be enough for the server to prepare and rotate into a new +# CA and mint a new SVID with the new CA. Check every three seconds that the +# is valid. +NUMCHECKS=15 +CHECKINTERVAL=3 +for ((i=1;i<=NUMCHECKS;i++)); do + log-info "checking X509-SVID ($i of $NUMCHECKS)..." + docker compose exec -T spire-agent \ + /opt/spire/bin/spire-agent api fetch x509 || fail-now "SVID check failed" + sleep "${CHECKINTERVAL}" +done diff --git a/test/integration/cassandra-suites/rotation/README.md b/test/integration/cassandra-suites/rotation/README.md new file mode 100644 index 0000000000..0e8ce748d1 --- /dev/null +++ b/test/integration/cassandra-suites/rotation/README.md @@ -0,0 +1,6 @@ +# Rotation Suite + +## Description + +This suite sets a very low TTLs and ensures that workload SVIDs are valid +across many SVID and SPIRE server CA rotation periods. diff --git a/test/integration/cassandra-suites/rotation/conf/agent/agent.conf b/test/integration/cassandra-suites/rotation/conf/agent/agent.conf new file mode 100644 index 0000000000..f79c4e9b06 --- /dev/null +++ b/test/integration/cassandra-suites/rotation/conf/agent/agent.conf @@ -0,0 +1,26 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/rotation/conf/server/server.conf b/test/integration/cassandra-suites/rotation/conf/server/server.conf new file mode 100644 index 0000000000..f1f693c0bb --- /dev/null +++ b/test/integration/cassandra-suites/rotation/conf/server/server.conf @@ -0,0 +1,48 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1m" + default_x509_svid_ttl = "10s" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/rotation/docker-compose.yaml b/test/integration/cassandra-suites/rotation/docker-compose.yaml new file mode 100644 index 0000000000..d8c2241661 --- /dev/null +++ b/test/integration/cassandra-suites/rotation/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + spire-server: + image: spire-server:latest-local + env_file: + - ../.env + hostname: spire-server + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + env_file: + - ../.env + hostname: spire-agent + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/rotation/teardown b/test/integration/cassandra-suites/rotation/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/rotation/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/self-test/00-ensure-command-failure-fails-step b/test/integration/cassandra-suites/self-test/00-ensure-command-failure-fails-step new file mode 100755 index 0000000000..1cb8f10b7f --- /dev/null +++ b/test/integration/cassandra-suites/self-test/00-ensure-command-failure-fails-step @@ -0,0 +1,14 @@ +onexit() { + if [ $? != 0 ]; then + exit 0 + else + fail-now "Script should have failed." + fi +} + +trap onexit EXIT + +log-info "Testing that command failure fails step script..." +false +log-warn "Should not get here!" +exit 0 diff --git a/test/integration/cassandra-suites/self-test/README.md b/test/integration/cassandra-suites/self-test/README.md new file mode 100644 index 0000000000..6d24347f4a --- /dev/null +++ b/test/integration/cassandra-suites/self-test/README.md @@ -0,0 +1,4 @@ +# Self Test + +This test suite ensures properties about test execution by the integration test +framework. diff --git a/test/integration/cassandra-suites/self-test/teardown b/test/integration/cassandra-suites/self-test/teardown new file mode 100755 index 0000000000..e69de29bb2 diff --git a/test/integration/cassandra-suites/spire-server-cli/01-start-server b/test/integration/cassandra-suites/spire-server-cli/01-start-server new file mode 100755 index 0000000000..60bdd68029 --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/01-start-server @@ -0,0 +1,5 @@ +#!/bin/bash + +docker build --target spire-server-alpine -t spire-server-alpine . +docker-spire-server-up spire-server + diff --git a/test/integration/cassandra-suites/spire-server-cli/02-bundle b/test/integration/cassandra-suites/spire-server-cli/02-bundle new file mode 100755 index 0000000000..186ef2b2a8 --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/02-bundle @@ -0,0 +1,70 @@ +#!/bin/bash + +# Verify 'bundle count' correctly indicates a single bundle (the server bundle) +docker compose exec -T spire-server /opt/spire/bin/spire-server bundle count | grep 1 || fail-now "failed to count 1 bundle" + +# Verify 'bundle show' +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show | openssl x509 -text -noout | grep URI:spiffe://domain.test || fail-now "failed to show bundle (pem)" + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show -format spiffe || fail-now "failed to show bundle (spiffe)" + +# Verify federated bundle can be created (pem) +docker compose exec -T spire-server \ + ash -c " +cat /opt/spire/conf/fixture/ca.pem | + /opt/spire/bin/spire-server bundle set -id spiffe://federated.td" || fail-now "failed to create bundle (pem)" +docker compose exec -T spire-server \ + ash -c " +/opt/spire/bin/spire-server bundle list -id spiffe://federated.td | + grep 'makw2ekuHKWC4hBhCkpr5qY4bI8YUcXfxg/1AiEA67kMyH7bQnr7OVLUrL+b9ylA'" || fail-now "federated bundle not found" + +# Verify federated bundle can be updated (pem) +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle set -id spiffe://federated.td -path /opt/spire/conf/fixture/ca2.pem || fail-now "failed to set bundle with path (pem)" +docker compose exec -T spire-server \ + ash -c " +/opt/spire/bin/spire-server bundle list -id spiffe://federated.td | + grep 'q+2ZoNyl4udPj7IMYIGX8yuCNRmh7m3d9tvoDgIgbS26wSwDjngGqdiHHL8fTcg'" || fail-now "federated bundle was not updated" + +# Verify federated bundle can be created (spiffe) +docker compose exec -T spire-server \ + ash -c " +cat /opt/spire/conf/fixture/ca.spiffe | + /opt/spire/bin/spire-server bundle set -id spiffe://federated2.td -format spiffe" || fail-now "failed to create bundle (spiffe)" +docker compose exec -T spire-server \ + ash -c " +/opt/spire/bin/spire-server bundle list -id spiffe://federated2.td -format spiffe | + grep 'fK-wKTnKL7KFLM27lqq5DC-bxrVaH6rDV-IcCSEOeL4'" || fail-now "federated bundle not found" + +# Verify 'bundle count' correctly indicates two bundles +docker compose exec -T spire-server /opt/spire/bin/spire-server bundle count | grep 3 || fail-now "failed to count 3 bundles" + +# Verify federated bundle can be updated (pem) +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle set -id spiffe://federated2.td -path /opt/spire/conf/fixture/ca2.spiffe -format spiffe || fail-now "failed to set bundle with path (spiffe)" +docker compose exec -T spire-server \ + ash -c " +/opt/spire/bin/spire-server bundle list -id spiffe://federated2.td -format spiffe | + grep 'HxVuaUnxgi431G5D3g9hqeaQhEbsyQZXmaas7qsUC_c'" || fail-now "federated bundle was not updated" + +# Verify 'bundle list' contains both federated bundles +docker compose exec -T spire-server \ + ash -c " +/opt/spire/bin/spire-server bundle list | + grep -E 'federated.td|federated2.td' -c | grep 2" || fail-now "Unexpected amout of federated bundles" + +# Verify delete +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle delete -id spiffe://federated.td || fail-now "failed to delete federated bundle" +docker compose exec -T spire-server \ + ash -c " +/opt/spire/bin/spire-server bundle list | + grep -E 'federated.td|federated2.td' -c | grep 1" || fail-now "Unexpected amout of federated bundles" + +# Verify 'bundle count' correctly indicates two bundles (server bundle and one federated bundle) +docker compose exec -T spire-server /opt/spire/bin/spire-server bundle count | grep 2 || fail-now "failed to count 2 bundles" + +# Verify that there are no WIT-SVIDs by default +docker compose exec -T spire-server /opt/spire/bin/spire-server bundle show -output json | jq -e '.wit_authorities | length == 0' || fail-now "wit_authorities should have been empty" diff --git a/test/integration/cassandra-suites/spire-server-cli/03-entry b/test/integration/cassandra-suites/spire-server-cli/03-entry new file mode 100755 index 0000000000..abb969531b --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/03-entry @@ -0,0 +1,313 @@ +#!/bin/bash + +# Create bundles of federated trust domains to be used by other commands +docker compose exec -T spire-server \ + ash -c " +cat /opt/spire/conf/fixture/ca.pem | + /opt/spire/bin/spire-server bundle set -id spiffe://federated1.test" || fail-now "failed to create federated bundle 1" + +docker compose exec -T spire-server \ + ash -c " +cat /opt/spire/conf/fixture/ca.pem | + /opt/spire/bin/spire-server bundle set -id spiffe://federated2.test" || fail-now "failed to create federated bundle 2" + +# Verify entry create +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -selector s1:v1 \ + -parentID spiffe://domain.test/parent \ + -spiffeID spiffe://domain.test/child1 \ + -federatesWith spiffe://federated1.test \ + -admin || fail-now "failed to create entry 1" + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -selector notUpdated:notUpdated \ + -parentID spiffe://domain.test/parentNotUpdated \ + -spiffeID spiffe://domain.test/child2NotUpdated \ + -downstream || fail-now "failed to create entry 2" + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -selector otherS:otherV \ + -spiffeID spiffe://domain.test/otherChild \ + -node \ + -dns dnsname1 \ + -x509SVIDTTL 123 || fail-now "failed to create entry 3" + +echo "Entries created successfully... sleeping" +sleep 300 +# Verify entry count correctly indicates three entries +docker compose exec -T spire-server /opt/spire/bin/spire-server entry count | grep 3 || fail-now "failed to count 3 entries" + +# Verify entry show and set variables entryID1, entryID2 and entryID3 +# Entry 1 +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -spiffeID spiffe://domain.test/child1)" + +echo "$showResult" | grep "Found 1 entry" || fail-now "failed to show entry 1" + +echo "$showResult" | grep "SPIFFE ID" | grep "spiffe://domain.test/child1" || fail-now "failed to show entry 1, unexpected SPIFFE ID" + +echo "$showResult" | grep "Parent ID" | grep "spiffe://domain.test/parent" || fail-now "failed to show entry 1, unexpected Parent ID" + +echo "$showResult" | grep "Revision" | grep "0" || fail-now "failed to show entry 1, unexpected Revision number" + +echo $(echo "$showResult" | grep "Downstream" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 1, 'grep Downstream' should fail" + +echo "$showResult" | grep "TTL" | grep "default" || fail-now "failed to show entry 1, unexpected TTL" + +echo "$showResult" | grep "Selector" | grep "s1:v1" || fail-now "failed to show entry 1, expected Selector not found" + +echo "$showResult" | grep "FederatesWith" | grep "federated1.test" || fail-now "failed to show entry 1, expected federated domain not found" + +echo $(echo "$showResult" | grep "DNS name" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 1, 'grep DNS name' should fail" + +echo "$showResult" | grep "Admin" | grep "true" || fail-now "failed to show entry 1, unexpected Admin not true" + +entryID1="$(echo "$showResult" | grep "Entry ID")" || fail-now "failed to show entry 1, no Entry ID" +entryID1="${entryID1#*: }" + +# Entry 2 +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -spiffeID spiffe://domain.test/child2NotUpdated)" + +echo "$showResult" | grep "Found 1 entry" || fail-now "failed to show entry 2" + +echo "$showResult" | grep "SPIFFE ID" | grep "spiffe://domain.test/child2NotUpdated" || fail-now "failed to show entry 2, unexpected SPIFFE ID" + +echo "$showResult" | grep "Parent ID" | grep "spiffe://domain.test/parentNotUpdated" || fail-now "failed to show entry 2, unexpected Parent ID" + +echo "$showResult" | grep "Revision" | grep "0" || fail-now "failed to show entry 2, unexpected Revision number" + +echo "$showResult" | grep "Downstream" | grep "true" || fail-now "failed to show entry 2, unexpected Downstream not true" + +echo "$showResult" | grep "TTL" | grep "default" || fail-now "failed to show entry 2, unexpected TTL" + +echo "$showResult" | grep "Selector" | grep "notUpdated:notUpdated" || fail-now "failed to show entry 2, expected Selector not found" + +echo $(echo "$showResult" | grep "FederatesWith" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 2, 'grep FederatesWith' should fail" + +echo $(echo "$showResult" | grep "DNS name" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 2, 'grep DNS name' should fail" + +echo $(echo "$showResult" | grep "Admin" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 2, 'grep Admin' should fail" + +entryID2="$(echo "$showResult" | grep "Entry ID")" || fail-now "failed to show entry 2, no Entry ID" +entryID2="${entryID2#*: }" + +# Entry 3 +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -spiffeID spiffe://domain.test/otherChild)" + +echo "$showResult" | grep "Found 1 entry" || fail-now "failed to show entry 3" + +echo "$showResult" | grep "SPIFFE ID" | grep "spiffe://domain.test/otherChild" || fail-now "failed to show entry 3, unexpected SPIFFE ID" + +echo "$showResult" | grep "Parent ID" | grep "spiffe://domain.test/spire/server" || fail-now "failed to show entry 3, unexpected Parent ID" + +echo "$showResult" | grep "Revision" | grep "0" || fail-now "failed to show entry 3, unexpected Revision number" + +echo $(echo "$showResult" | grep "Downstream" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 3, 'grep Downstream' should fail" + +echo "$showResult" | grep "TTL" | grep "123" || fail-now "failed to show entry 3, unexpected TTL" + +echo "$showResult" | grep "Selector" | grep "otherS:otherV" || fail-now "failed to show entry 3, expected Selector not found" + +echo $(echo "$showResult" | grep "FederatesWith" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 3, 'grep FederatesWith' should fail" + +echo "$showResult" | grep "DNS name" | grep "dnsname1" || fail-now "failed to show entry 3, expected DNS name not found" + +echo $(echo "$showResult" | grep "Admin" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 3, 'grep Admin' should fail" + +entryID3="$(echo "$showResult" | grep "Entry ID")" || fail-now "failed to show entry 3, no Entry ID" +entryID3="${entryID3#*: }" + +# Verify entry update +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry update \ + -entryID ${entryID1} \ + -selector s1:v1 \ + -parentID spiffe://domain.test/parent \ + -spiffeID spiffe://domain.test/child1 \ + -federatesWith spiffe://federated1.test \ + -x509SVIDTTL 456 || fail-now "failed to update entry 1" + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry update \ + -entryID ${entryID2} \ + -selector s1:v1 -selector s2:v2 \ + -parentID spiffe://domain.test/parent \ + -spiffeID spiffe://domain.test/child2 \ + -federatesWith spiffe://federated1.test -federatesWith spiffe://federated2.test \ + -dns dnsname2 || fail-now "failed to update entry 2" + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry update \ + -entryID ${entryID3} \ + -selector otherS:otherV \ + -spiffeID spiffe://domain.test/child3 \ + -parentID spiffe://domain.test/spire/server \ + -admin \ + -downstream || fail-now "failed to update entry 3" + +# Verify entry show after updates +# Entry 1 +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -spiffeID spiffe://domain.test/child1)" + +echo "$showResult" | grep "Found 1 entry" || fail-now "failed to show entry 1 after update" + +echo "$showResult" | grep "SPIFFE ID" | grep "spiffe://domain.test/child1" || fail-now "failed to show entry 1 after update, unexpected SPIFFE ID" + +echo "$showResult" | grep "Entry ID" | grep ${entryID1} || fail-now "failed to show entry 1 after update, unexpected Entry ID" + +echo "$showResult" | grep "Parent ID" | grep "spiffe://domain.test/parent" || fail-now "failed to show entry 1 after update, unexpected Parent ID" + +echo "$showResult" | grep "Revision" | grep "1" || fail-now "failed to show entry 1 after update, unexpected Revision number" + +echo $(echo "$showResult" | grep "Downstream" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 1 after update, 'grep Downstream' should fail" + +echo "$showResult" | grep "TTL" | grep "456" || fail-now "failed to show entry 1 after update, unexpected TTL" + +echo "$showResult" | grep "Selector" | grep "s1:v1" || fail-now "failed to show entry 1 after update, expected Selector not found" + +echo "$showResult" | grep "FederatesWith" | grep "federated1.test" || fail-now "failed to show entry 1 after update, expected federated domain not found" + +echo $(echo "$showResult" | grep "DNS name" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 1 after update, 'grep DNS name' should fail" + +echo $(echo "$showResult" | grep "Admin" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 1 after update, 'grep Admin' should fail" + +# Entry 2 +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -spiffeID spiffe://domain.test/child2)" + +echo "$showResult" | grep "Found 1 entry" || fail-now "failed to show entry 2 after update" + +echo "$showResult" | grep "SPIFFE ID" | grep "spiffe://domain.test/child2" || fail-now "failed to show entry 2 after update, unexpected SPIFFE ID" + +echo "$showResult" | grep "Entry ID" | grep ${entryID2} || fail-now "failed to show entry 1 after update, unexpected Entry ID" + +echo "$showResult" | grep "Parent ID" | grep "spiffe://domain.test/parent" || fail-now "failed to show entry 2 after update, unexpected Parent ID" + +echo "$showResult" | grep "Revision" | grep "1" || fail-now "failed to show entry 2 after update, unexpected Revision number" + +echo $(echo "$showResult" | grep "Downstream" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 2 after update, 'grep Downstream' should fail" + +echo "$showResult" | grep "TTL" | grep "default" || fail-now "failed to show entry 2 after update, unexpected TTL" + +echo "$showResult" | grep "Selector" | grep "s1:v1" || fail-now "failed to show entry 2 after update, expected Selector 1 not found" + +echo "$showResult" | grep "Selector" | grep "s2:v2" || fail-now "failed to show entry 2 after update, expected Selector 2 not found" + +echo "$showResult" | grep "FederatesWith" | grep "federated1.test" || fail-now "failed to show entry 2 after update, expected federated domain 1 not found" + +echo "$showResult" | grep "FederatesWith" | grep "federated2.test" || fail-now "failed to show entry 2 after update, expected federated domain 2 not found" + +echo "$showResult" | grep "DNS name" | grep "dnsname2" || fail-now "failed to show entry 2 after update, expected DNS name not found" + +echo $(echo "$showResult" | grep "Admin" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 2 after update, 'grep Admin' should fail" + +# Entry 3 +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -spiffeID spiffe://domain.test/child3)" + +echo "$showResult" | grep "Found 1 entry" || fail-now "failed to show entry 3 after update" + +echo "$showResult" | grep "SPIFFE ID" | grep "spiffe://domain.test/child3" || fail-now "failed to show entry 3 after update, unexpected SPIFFE ID" + +echo "$showResult" | grep "Entry ID" | grep ${entryID3} || fail-now "failed to show entry 3 after update, unexpected Entry ID" + +echo "$showResult" | grep "Parent ID" | grep "spiffe://domain.test/spire/server" || fail-now "failed to show entry 3 after update, unexpected Parent ID" + +echo "$showResult" | grep "Revision" | grep "1" || fail-now "failed to show entry 3 after update, unexpected Revision number" + +echo "$showResult" | grep "Downstream" | grep "true" || fail-now "failed to show entry 3 after update, unexpected Downstream not true" + +echo "$showResult" | grep "TTL" | grep "default" || fail-now "failed to show entry 3 after update, unexpected TTL" + +echo "$showResult" | grep "Selector" | grep "otherS:otherV" || fail-now "failed to show entry 3 after update, unexpected Selector" + +echo $(echo "$showResult" | grep "FederatesWith" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 3 after update, 'grep FederatesWith' should fail" + +echo $(echo "$showResult" | grep "DNS name" || echo "Failed when expected") \ + | grep "Failed when expected" || fail-now "failed to show entry 3 after update, 'grep DNS name' should fail" + +echo "$showResult" | grep "Admin" | grep "true" || fail-now "failed to show entry 3 after update, unexpected Admin not true" + +# Verify entry show using filters +# By parent +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -parentID spiffe://domain.test/parent)" + +echo "$showResult" | grep "Found 2 entries" || fail-now "failed to show entries by parentID" +echo "$showResult" | grep "Entry ID" | grep ${entryID1} || fail-now "failed to show entries by parentID, expected Entry ID 1 not found" +echo "$showResult" | grep "Entry ID" | grep ${entryID2} || fail-now "failed to show entries by parentID, expected Entry ID 2 not found" + +# By selectors (default matcher, SUPERSET) +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -selector s1:v1)" + +echo "$showResult" | grep "Found 2 entries" || fail-now "failed to show entry 1 by selector" +echo "$showResult" | grep ${entryID1} || fail-now "failed to show entry 1 by selector, unexpected Entry ID" +echo "$showResult" | grep ${entryID2} || fail-now "failed to show entry 1 by selector, unexpected Entry ID" + +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -selector s1:v1 -selector s2:v2)" + +echo "$showResult" | grep "Found 1 entry" || fail-now "failed to show entry 2 by selector" +echo "$showResult" | grep ${entryID2} || fail-now "failed to show entry 2 by selector, unexpected Entry ID" + +# By selectors (change matcher) +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -selector s1:v1 \ + -matchSelectorsOn exact)" + +echo "$showResult" | grep "Found 1 entry" || fail-now "failed to show entry 1 by selector" +echo "$showResult" | grep ${entryID1} || fail-now "failed to show entry 1 by selector, unexpected Entry ID" + +# Verify entry delete +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show)" + +echo "$showResult" | grep "Found 3 entries" || fail-now "failed to show entries before delete" +echo "$showResult" | grep "Entry ID" | grep ${entryID1} || fail-now "failed to show entries before delete, expected Entry ID 1 not found" +echo "$showResult" | grep "Entry ID" | grep ${entryID2} || fail-now "failed to show entries before delete, expected Entry ID 2 not found" +echo "$showResult" | grep "Entry ID" | grep ${entryID3} || fail-now "failed to show entries before delete, expected Entry ID 3 not found" + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry delete \ + -entryID ${entryID1} || fail-now "failed to delete entry 1" + +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show)" + +echo "$showResult" | grep "Found 2 entries" || fail-now "failed to show entries after delete" +echo "$showResult" | grep "Entry ID" | grep ${entryID2} || fail-now "failed to show entries after delete, expected Entry ID 2 not found" +echo "$showResult" | grep "Entry ID" | grep ${entryID3} || fail-now "failed to show entries after delete, expected Entry ID 3 not found" + +# Verify entry count correctly indicates two entries +docker compose exec -T spire-server /opt/spire/bin/spire-server entry count | grep 2 || fail-now "failed to count 2 entries" diff --git a/test/integration/cassandra-suites/spire-server-cli/04-bootstrap-agents b/test/integration/cassandra-suites/spire-server-cli/04-bootstrap-agents new file mode 100755 index 0000000000..fcd187964c --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/04-bootstrap-agents @@ -0,0 +1,35 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt + +# Set conf files for each agent +cp -R conf/agent/ conf/agent-1 +cp -R conf/agent/ conf/agent-2 +cp -R conf/agent/ conf/agent-3 + +# Set a different join token for each agent +# Agent 1 +log-info "generating join token for agent 1..." +TOKEN=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server token generate -spiffeID spiffe://domain.test/node1 | awk '{print $2}' | tr -d '\r') + +log-debug "using join token ${TOKEN} for agent 1..." +sed -i.bak "s#TOKEN#${TOKEN}#g" conf/agent-1/agent.conf + +# Agent 2 +log-info "generating join token for agent 2..." +TOKEN=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server token generate -spiffeID spiffe://domain.test/node2 | awk '{print $2}' | tr -d '\r') + +log-debug "using join token ${TOKEN} for agent 2..." +sed -i.bak "s#TOKEN#${TOKEN}#g" conf/agent-2/agent.conf + +# Agent 3 +log-info "generating join token for agent 3..." +TOKEN=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server token generate -spiffeID spiffe://domain.test/node3 | awk '{print $2}' | tr -d '\r') + +log-debug "using join token ${TOKEN} for agent 3..." +sed -i.bak "s#TOKEN#${TOKEN}#g" conf/agent-3/agent.conf diff --git a/test/integration/cassandra-suites/spire-server-cli/05-start-agents b/test/integration/cassandra-suites/spire-server-cli/05-start-agents new file mode 100755 index 0000000000..d51a3e7262 --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/05-start-agents @@ -0,0 +1,5 @@ +#!/bin/bash + +docker-up spire-agent-1 +docker-up spire-agent-2 +docker-up spire-agent-3 diff --git a/test/integration/cassandra-suites/spire-server-cli/06-agent b/test/integration/cassandra-suites/spire-server-cli/06-agent new file mode 100755 index 0000000000..8703d104f6 --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/06-agent @@ -0,0 +1,13 @@ +#!/bin/bash + +# Verify agent count correctly indicates three agents +MAXCHECKS=30 +CHECKINTERVAL=1 +for ((i=1;i<=MAXCHECKS;i++)); do + log-info "checking that the server counts 3 agents ($i of $MAXCHECKS max)..." + if docker compose exec -T spire-server /opt/spire/bin/spire-server agent count | grep 3; then + exit 0 + fi + sleep "${CHECKINTERVAL}" +done +fail-now "failed to count 3 agents" diff --git a/test/integration/cassandra-suites/spire-server-cli/07-agent-details b/test/integration/cassandra-suites/spire-server-cli/07-agent-details new file mode 100755 index 0000000000..ee13b95fc0 --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/07-agent-details @@ -0,0 +1,113 @@ +#!/bin/bash + +# Verify the 3 agents were created +log-info "listing agents..." +listResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent list)" + +echo "$listResult" +echo "$listResult" | grep "Found 3 attested agents" || fail-now "failed to list the 3 agents initially" +echo "$listResult" | grep "Attestation type" | grep "join_token" || fail-now "unexpected agents attestation type" + +# Get agent SPIFFE IDs from entries, knowing they were attested using join-token +log-info "verifying details for each agent from list..." +# Agent 1 +agentID1="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -spiffeID spiffe://domain.test/node1)" +agentID1="$(echo "$agentID1" | grep "Parent ID")" || fail-now "failed to extract agentID1" +agentID1="${agentID1#*: }" + +# Agent 2 +agentID2="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -spiffeID spiffe://domain.test/node2)" +agentID2="$(echo "$agentID2" | grep "Parent ID")" || fail-now "failed to extract agentID2" +agentID2="${agentID2#*: }" + +# Agent 3 +agentID3="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show \ + -spiffeID spiffe://domain.test/node3)" +agentID3="$(echo "$agentID3" | grep "Parent ID")" || fail-now "failed to extract agentID3" +agentID3="${agentID3#*: }" + +# Verify agentIDs match +echo "$listResult" | grep "$agentID1" || fail-now "agentID1=$agentID1 not found in agentIDs list" +echo "$listResult" | grep "$agentID2" || fail-now "agentID2=$agentID2 not found in agentIDs list" +echo "$listResult" | grep "$agentID3" || fail-now "agentID3=$agentID3 not found in agentIDs list" + +# Verify agent show +log-info "verifying details for each agent from show..." +# Agent 1 +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent show -spiffeID $agentID1)" + +echo "$showResult" +echo "$showResult" | grep "Found an attested agent given its SPIFFE ID" || fail-now "failed to show agent 1" +echo "$showResult" | grep "SPIFFE ID" | grep "$agentID1" || fail-now "unexpected SPIFFE ID for agent 1" +echo "$showResult" | grep "Attestation type" | grep "join_token" || fail-now "unexpected attestation type for agent 1" + +# Agent 2 +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent show -spiffeID $agentID2)" + +echo "$showResult" +echo "$showResult" | grep "Found an attested agent given its SPIFFE ID" || fail-now "failed to show agent 2" +echo "$showResult" | grep "SPIFFE ID" | grep "$agentID2" || fail-now "unexpected SPIFFE ID for agent 2" +echo "$showResult" | grep "Attestation type" | grep "join_token" || fail-now "unexpected attestation type for agent 2" + +# Agent 3 +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent show -spiffeID $agentID3)" + +echo "$showResult" +echo "$showResult" | grep "Found an attested agent given its SPIFFE ID" || fail-now "failed to show agent 3" +echo "$showResult" | grep "SPIFFE ID" | grep "$agentID3" || fail-now "unexpected SPIFFE ID for agent 3" +echo "$showResult" | grep "Attestation type" | grep "join_token" || fail-now "unexpected attestation type for agent 3" + +# Verify agent ban +log-info "banning and evicting agent 1..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent ban -spiffeID "$agentID1" | grep "Agent banned successfully" || fail-now "failed to ban agent 1" + +# Verify agent list after ban +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent list | grep "Found 3 attested agents" || fail-now "failed to list the agents after ban" + +# Verify agent show after ban Agent 1 +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent show -spiffeID $agentID1 | grep "Banned : true" || fail-now "agent 1 was not banned" + +# Verify agent evict +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent evict -spiffeID "$agentID1" | grep "Agent evicted successfully" || fail-now "failed to evict agent 1" + +# Verify agent list after evict +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent list | grep "Found 2 attested agents" || fail-now "failed to list the agents after evict" + +# Verify agent show after evict +log-info "verifying new agent show..." +# Agent 1 +echo "$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent show -spiffeID $agentID1 || echo "OK: agent 1 not found")" \ + | grep "OK: agent 1 not found" || fail-now "agent 1 was found after evict" + +# Agent 2 +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent show -spiffeID $agentID2)" + +echo "$showResult" +echo "$showResult" | grep "Found an attested agent given its SPIFFE ID" || fail-now "failed to show agent 2 after evict" +echo "$showResult" | grep "SPIFFE ID" | grep "$agentID2" || fail-now "unexpected SPIFFE ID for agent 2 after evict" +echo "$showResult" | grep "Attestation type" | grep "join_token" || fail-now "unexpected attestation type for agent 2 after evict" + +# Agent 3 +showResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server agent show -spiffeID $agentID3)" + +echo "$showResult" +echo "$showResult" | grep "Found an attested agent given its SPIFFE ID" || fail-now "failed to show agent 3 after evict" +echo "$showResult" | grep "SPIFFE ID" | grep "$agentID3" || fail-now "unexpected SPIFFE ID for agent 3 after evict" +echo "$showResult" | grep "Attestation type" | grep "join_token" || fail-now "unexpected attestation type for agent 3 after evict" diff --git a/test/integration/cassandra-suites/spire-server-cli/08-validate b/test/integration/cassandra-suites/spire-server-cli/08-validate new file mode 100755 index 0000000000..e5204339c3 --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/08-validate @@ -0,0 +1,11 @@ +#!/bin/bash + +log-info "validating config" + +if validateResult="$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server validate -config /opt/spire/conf/server/server_bad.conf 2>&1)"; then + fail-now "'validate' did not return with non-zero exit code" +fi +echo "$validateResult" +echo "$validateResult" | grep -q "failed to read plugin configuration: open conf/server/x509pop.conf: no such file or directory" || fail-now "No error due to missing plugin_data_file" +echo "$validateResult" | grep -q "'cert_file_path' and 'key_file_path' must be set and not empty" || fail-now "No error due to missing required fields" diff --git a/test/integration/cassandra-suites/spire-server-cli/Dockerfile b/test/integration/cassandra-suites/spire-server-cli/Dockerfile new file mode 100644 index 0000000000..47f3558bfa --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/Dockerfile @@ -0,0 +1,4 @@ +FROM alpine:3.18 AS spire-server-alpine +RUN apk add --no-cache --update openssl +COPY --from=spire-server:latest-local /opt/spire/bin/spire-server /opt/spire/bin/spire-server +ENTRYPOINT ["/opt/spire/bin/spire-server", "run"] diff --git a/test/integration/cassandra-suites/spire-server-cli/README.md b/test/integration/cassandra-suites/spire-server-cli/README.md new file mode 100644 index 0000000000..9e8e97ef44 --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/README.md @@ -0,0 +1,5 @@ +# SPIRE Server CLI Suite + +## Description + +This suite validates all SPIRE Server CLI commands. diff --git a/test/integration/cassandra-suites/spire-server-cli/conf/agent/agent.conf b/test/integration/cassandra-suites/spire-server-cli/conf/agent/agent.conf new file mode 100644 index 0000000000..f18b9d2d2f --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/conf/agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" + + # The TOKEN is replaced with the actual token generated by SPIRE server + # during the test run. + join_token = "TOKEN" +} + +plugins { + NodeAttestor "join_token" { + plugin_data { + } + } + KeyManager "memory" { + plugin_data { + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/spire-server-cli/conf/fixture/ca.pem b/test/integration/cassandra-suites/spire-server-cli/conf/fixture/ca.pem new file mode 100644 index 0000000000..70533024f2 --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/conf/fixture/ca.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBKjCB0aADAgECAgEBMAoGCCqGSM49BAMCMAAwIhgPMDAwMTAxMDEwMDAwMDBa +GA85OTk5MTIzMTIzNTk1OVowADBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABHyv +sCk5yi+yhSzNu5aquQwvm8a1Wh+qw1fiHAkhDni+wq+g3TQWxYlV51TCPH030yXs +RxvujD4hUUaIQrXk4KKjODA2MA8GA1UdEwEB/wQFMAMBAf8wIwYDVR0RAQH/BBkw +F4YVc3BpZmZlOi8vZG9tYWluMS50ZXN0MAoGCCqGSM49BAMCA0gAMEUCIA2dO09X +makw2ekuHKWC4hBhCkpr5qY4bI8YUcXfxg/1AiEA67kMyH7bQnr7OVLUrL+b9ylA +dZglS5kKnYigmwDh+/U= +-----END CERTIFICATE----- diff --git a/test/integration/cassandra-suites/spire-server-cli/conf/fixture/ca.spiffe b/test/integration/cassandra-suites/spire-server-cli/conf/fixture/ca.spiffe new file mode 100644 index 0000000000..455031d11f --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/conf/fixture/ca.spiffe @@ -0,0 +1,22 @@ +{ + "keys": [ + { + "use": "x509-svid", + "kty": "EC", + "crv": "P-256", + "x": "fK-wKTnKL7KFLM27lqq5DC-bxrVaH6rDV-IcCSEOeL4", + "y": "wq-g3TQWxYlV51TCPH030yXsRxvujD4hUUaIQrXk4KI", + "x5c": [ + "MIIBKjCB0aADAgECAgEBMAoGCCqGSM49BAMCMAAwIhgPMDAwMTAxMDEwMDAwMDBaGA85OTk5MTIzMTIzNTk1OVowADBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABHyvsCk5yi+yhSzNu5aquQwvm8a1Wh+qw1fiHAkhDni+wq+g3TQWxYlV51TCPH030yXsRxvujD4hUUaIQrXk4KKjODA2MA8GA1UdEwEB/wQFMAMBAf8wIwYDVR0RAQH/BBkwF4YVc3BpZmZlOi8vZG9tYWluMS50ZXN0MAoGCCqGSM49BAMCA0gAMEUCIA2dO09Xmakw2ekuHKWC4hBhCkpr5qY4bI8YUcXfxg/1AiEA67kMyH7bQnr7OVLUrL+b9ylAdZglS5kKnYigmwDh+/U=" + ] + }, + { + "use": "jwt-svid", + "kty": "EC", + "kid": "KID", + "crv": "P-256", + "x": "fK-wKTnKL7KFLM27lqq5DC-bxrVaH6rDV-IcCSEOeL4", + "y": "wq-g3TQWxYlV51TCPH030yXsRxvujD4hUUaIQrXk4KI" + } + ] +} diff --git a/test/integration/cassandra-suites/spire-server-cli/conf/fixture/ca2.pem b/test/integration/cassandra-suites/spire-server-cli/conf/fixture/ca2.pem new file mode 100644 index 0000000000..19f891e5e2 --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/conf/fixture/ca2.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBKjCB0aADAgECAgEBMAoGCCqGSM49BAMCMAAwIhgPMDAwMTAxMDEwMDAwMDBa +GA85OTk5MTIzMTIzNTk1OVowADBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABB8V +bmlJ8YIuN9RuQ94PYanmkIRG7MkGV5mmrO6rFAv3SFd/uVlwYNkXrh0219eHUSD4 +o+4RGXoiMFJKysw5GK6jODA2MA8GA1UdEwEB/wQFMAMBAf8wIwYDVR0RAQH/BBkw +F4YVc3BpZmZlOi8vZG9tYWluMi50ZXN0MAoGCCqGSM49BAMCA0gAMEUCIQDMKwYt +q+2ZoNyl4udPj7IMYIGX8yuCNRmh7m3d9tvoDgIgbS26wSwDjngGqdiHHL8fTcgg +diIqWtxAqBLFrx8zNS4= +-----END CERTIFICATE----- diff --git a/test/integration/cassandra-suites/spire-server-cli/conf/fixture/ca2.spiffe b/test/integration/cassandra-suites/spire-server-cli/conf/fixture/ca2.spiffe new file mode 100644 index 0000000000..1243327aff --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/conf/fixture/ca2.spiffe @@ -0,0 +1,14 @@ +{ + "keys": [ + { + "use": "x509-svid", + "kty": "EC", + "crv": "P-256", + "x": "HxVuaUnxgi431G5D3g9hqeaQhEbsyQZXmaas7qsUC_c", + "y": "SFd_uVlwYNkXrh0219eHUSD4o-4RGXoiMFJKysw5GK4", + "x5c": [ + "MIIBKjCB0aADAgECAgEBMAoGCCqGSM49BAMCMAAwIhgPMDAwMTAxMDEwMDAwMDBaGA85OTk5MTIzMTIzNTk1OVowADBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABB8VbmlJ8YIuN9RuQ94PYanmkIRG7MkGV5mmrO6rFAv3SFd/uVlwYNkXrh0219eHUSD4o+4RGXoiMFJKysw5GK6jODA2MA8GA1UdEwEB/wQFMAMBAf8wIwYDVR0RAQH/BBkwF4YVc3BpZmZlOi8vZG9tYWluMi50ZXN0MAoGCCqGSM49BAMCA0gAMEUCIQDMKwYtq+2ZoNyl4udPj7IMYIGX8yuCNRmh7m3d9tvoDgIgbS26wSwDjngGqdiHHL8fTcggdiIqWtxAqBLFrx8zNS4=" + ] + } + ] +} diff --git a/test/integration/cassandra-suites/spire-server-cli/conf/server/server.conf b/test/integration/cassandra-suites/spire-server-cli/conf/server/server.conf new file mode 100644 index 0000000000..db738a8d44 --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/conf/server/server.conf @@ -0,0 +1,43 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/spire-server-cli/conf/server/server_bad.conf b/test/integration/cassandra-suites/spire-server-cli/conf/server/server_bad.conf new file mode 100644 index 0000000000..3ff6082913 --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/conf/server/server_bad.conf @@ -0,0 +1,54 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1h" + default_x509_svid_ttl = "10m" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + KeyManager "memory" { + plugin_data = {} + } + + NodeAttestor "x509pop" { + # should trigger error due to file not found + plugin_data_file = "conf/server/x509pop.conf" + } + + UpstreamAuthority "disk" { + plugin_data { + # should trigger validation error due to missing fields + } + } +} diff --git a/test/integration/cassandra-suites/spire-server-cli/docker-compose.yaml b/test/integration/cassandra-suites/spire-server-cli/docker-compose.yaml new file mode 100644 index 0000000000..807fca1503 --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/docker-compose.yaml @@ -0,0 +1,33 @@ +services: + spire-server: + image: spire-server-alpine + env_file: + - ../.env + hostname: spire-server + volumes: + - ./conf/server:/opt/spire/conf/server + - ./conf/fixture:/opt/spire/conf/fixture + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent-1: + image: spire-agent:latest-local + env_file: + - ../.env + volumes: + - ./conf/agent-1:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + spire-agent-2: + image: spire-agent:latest-local + env_file: + - ../.env + volumes: + - ./conf/agent-2:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] + spire-agent-3: + image: spire-agent:latest-local + env_file: + - ../.env + volumes: + - ./conf/agent-3:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/spire-server-cli/teardown b/test/integration/cassandra-suites/spire-server-cli/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/spire-server-cli/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/svidstore/00-setup b/test/integration/cassandra-suites/svidstore/00-setup new file mode 100755 index 0000000000..55454f965f --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/00-setup @@ -0,0 +1,7 @@ +#!/bin/bash + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent + +"${ROOTDIR}/setup/svidstore/build.sh" "check" "${RUNDIR}/conf/server/checkstoredsvids" + +"${ROOTDIR}/setup/svidstore/build.sh" "plugin" "${RUNDIR}/conf/agent/disk-plugin" diff --git a/test/integration/cassandra-suites/svidstore/01-start-server b/test/integration/cassandra-suites/svidstore/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/svidstore/02-bootstrap-agent b/test/integration/cassandra-suites/svidstore/02-bootstrap-agent new file mode 100755 index 0000000000..8ee7d32c26 --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/02-bootstrap-agent @@ -0,0 +1,5 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt diff --git a/test/integration/cassandra-suites/svidstore/03-start-agent b/test/integration/cassandra-suites/svidstore/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/svidstore/04-create-entries b/test/integration/cassandra-suites/svidstore/04-create-entries new file mode 100755 index 0000000000..14cea19c71 --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/04-create-entries @@ -0,0 +1,44 @@ +#!/bin/bash + +source ./common + +log-debug "creating registration entries that must have it's SVIDs stored ..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/stored-1" \ + -selector "disk:name:stored-1" \ + -storeSVID true +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/stored-2" \ + -selector "disk:name:stored-2" \ + -storeSVID true +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/stored-3" \ + -selector "disk:name:stored-3" \ + -storeSVID true + +check-synced-entry "spire-agent" "spiffe://domain.test/stored-1" +check-synced-entry "spire-agent" "spiffe://domain.test/stored-2" +check-synced-entry "spire-agent" "spiffe://domain.test/stored-3" + +log-debug "creating registration entries that should not have the SVID stored..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/not-stored-1" \ + -selector "disk:name:not-stored-1" +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/not-stored-2" \ + -selector "disk:name:not-stored-2" + +check-synced-entry "spire-agent" "spiffe://domain.test/not-stored-1" +check-synced-entry "spire-agent" "spiffe://domain.test/not-stored-2" + +check-stored-svids diff --git a/test/integration/cassandra-suites/svidstore/05-update-entries b/test/integration/cassandra-suites/svidstore/05-update-entries new file mode 100755 index 0000000000..1a945d5c4d --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/05-update-entries @@ -0,0 +1,30 @@ +#!/bin/bash + +source ./common + +log-debug "updating registration entries that has stored SVIDs..." +ids=$(docker compose exec -T spire-server /opt/spire/bin/spire-server entry show -output json | jq -r '.entries[] | select(.store_svid == true) | .id') +for id in $ids; do + docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry update \ + -entryID $id \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/updated-$id" \ + -selector "disk:name:$id" +done + +log-debug "updating registration entries that don't have stored SVIDs..." +ids=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show -output json | jq -r '.entries[] | select(.spiffe_id.path | contains("not-stored")) | .id') +for id in $ids; do + docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry update \ + -entryID "$id" \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/now-stored-$id" \ + -selector "disk:name:stored-$id" \ + -storeSVID true + echo "$id" +done + +check-stored-svids diff --git a/test/integration/cassandra-suites/svidstore/06-delete-entries b/test/integration/cassandra-suites/svidstore/06-delete-entries new file mode 100755 index 0000000000..f239702a4a --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/06-delete-entries @@ -0,0 +1,11 @@ +#!/bin/bash + +source ./common + +log-debug "deleting all registration entries..." +ids=$(docker compose exec -T spire-server /opt/spire/bin/spire-server entry show -output json | jq -r '.entries[] | .id') +for id in $ids; do + docker compose exec -T spire-server /opt/spire/bin/spire-server entry delete -entryID $id +done + +check-deleted-svids diff --git a/test/integration/cassandra-suites/svidstore/README.md b/test/integration/cassandra-suites/svidstore/README.md new file mode 100644 index 0000000000..a6e1361209 --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/README.md @@ -0,0 +1,14 @@ +# SVID store suite + +## Description + +This suite validates the core logic of the SVID store feature. It uses a custom SVIDStore plugin that stores the SVIDs in disk. +The suite is composed of the following tests: + +1. Start spire server and agent loading the custom plugin used for testing. +2. Create registration entries with and without the `storeSVID` flag. +3. Check that the required SVIDs are stored in the file. +4. Update entries, removing the `storeSVID` flag from the ones that has it, and adding it to the ones that don't. +5. Check that the required SVIDs are stored in the file. +6. Delete all entries. +7. Check that the file is empty. diff --git a/test/integration/cassandra-suites/svidstore/common b/test/integration/cassandra-suites/svidstore/common new file mode 100644 index 0000000000..4aeb5974a8 --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/common @@ -0,0 +1,53 @@ +#!/bin/bash + +check-stored-svids() { + stored_ids=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show -output json | jq -r '.entries[] | select(.store_svid == true) | .id') + + for id in $stored_ids; do + found=0 + MAXCHECKS=10 + CHECKINTERVAL=1 + for ((i=1;i<=MAXCHECKS;i++)); do + log-info "checking for stored entry ($i of $MAXCHECKS max)..." + docker compose logs "spire-agent" + if docker compose logs "spire-agent" | grep '"SVID stored successfully" entry='"$id"''; then + found=1 + break + fi + sleep "${CHECKINTERVAL}" + done + + if [ "$found" -eq 0 ]; then + fail-now "timed out waiting for agent to store svid" + fi + done + + docker compose exec -u 1000 -T spire-server \ + /opt/spire/conf/server/checkstoredsvids /opt/spire/conf/agent/svids.json || fail-now "failed to check stored svids" +} + + +check-deleted-svids() { + stored_ids=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show -output json | jq -r '.entries[] | select(.store_svid == true) | .id') + + no_entries=0 + MAXCHECKS=10 + CHECKINTERVAL=1 + for ((i=1;i<=MAXCHECKS;i++)); do + stored_ids=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry show -output json | jq -r '.entries[] | select(.store_svid == true) | .id') + if [ -z "$stored_ids" ]; then + no_entries=1 + fi + sleep "${CHECKINTERVAL}" + done + + if [ "$no_entries" -eq 0 ]; then + fail-now "timed out waiting for agent to delete all svids" + fi + + docker compose exec -u 1000 -T spire-server \ + /opt/spire/conf/server/checkstoredsvids /opt/spire/conf/agent/svids.json || fail-now "failed to check stored svids" +} diff --git a/test/integration/cassandra-suites/svidstore/conf/agent/agent.conf b/test/integration/cassandra-suites/svidstore/conf/agent/agent.conf new file mode 100644 index 0000000000..df197dfb45 --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/conf/agent/agent.conf @@ -0,0 +1,32 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } + SVIDStore "disk" { + plugin_cmd = "/opt/spire/conf/agent/disk-plugin" + plugin_data { + svids_path = "/opt/spire/conf/agent/svids.json" + } + } +} diff --git a/test/integration/cassandra-suites/svidstore/conf/server/server.conf b/test/integration/cassandra-suites/svidstore/conf/server/server.conf new file mode 100644 index 0000000000..808b96bf73 --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/conf/server/server.conf @@ -0,0 +1,48 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "20m" + default_x509_svid_ttl = "10m" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/svidstore/docker-compose.yaml b/test/integration/cassandra-suites/svidstore/docker-compose.yaml new file mode 100644 index 0000000000..b6ea06473c --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/docker-compose.yaml @@ -0,0 +1,21 @@ +services: + spire-server: + image: spire-server:latest-local + hostname: spire-server + env_file: + - ../.env + volumes: + - ./conf/server:/opt/spire/conf/server + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + hostname: spire-agent + env_file: + - ../.env + depends_on: ["spire-server"] + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/svidstore/teardown b/test/integration/cassandra-suites/svidstore/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/svidstore/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/sync-authorized-entries/01-start-server b/test/integration/cassandra-suites/sync-authorized-entries/01-start-server new file mode 100755 index 0000000000..cf8a05a3f6 --- /dev/null +++ b/test/integration/cassandra-suites/sync-authorized-entries/01-start-server @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-spire-server-up spire-server diff --git a/test/integration/cassandra-suites/sync-authorized-entries/02-bootstrap-agents b/test/integration/cassandra-suites/sync-authorized-entries/02-bootstrap-agents new file mode 100755 index 0000000000..2d85b07751 --- /dev/null +++ b/test/integration/cassandra-suites/sync-authorized-entries/02-bootstrap-agents @@ -0,0 +1,13 @@ +#!/bin/bash + +log-debug "bootstrapping agent..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server bundle show > conf/agent/bootstrap.crt + +log-info "generating join token..." +TOKEN=$(docker compose exec -T spire-server \ + /opt/spire/bin/spire-server token generate -spiffeID spiffe://domain.test/node | awk '{print $2}' | tr -d '\r') + +# Inserts the join token into the agent configuration +log-debug "using join token ${TOKEN}..." +sed -i.bak "s#TOKEN#${TOKEN}#g" conf/agent/agent.conf diff --git a/test/integration/cassandra-suites/sync-authorized-entries/03-start-agent b/test/integration/cassandra-suites/sync-authorized-entries/03-start-agent new file mode 100755 index 0000000000..ac36d05f0d --- /dev/null +++ b/test/integration/cassandra-suites/sync-authorized-entries/03-start-agent @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-up spire-agent diff --git a/test/integration/cassandra-suites/sync-authorized-entries/04-create-workload-entries b/test/integration/cassandra-suites/sync-authorized-entries/04-create-workload-entries new file mode 100755 index 0000000000..5f05cc0b83 --- /dev/null +++ b/test/integration/cassandra-suites/sync-authorized-entries/04-create-workload-entries @@ -0,0 +1,32 @@ +#!/bin/bash + +# We need at least 500 entries to make sure we test the SyncAuthorizedEntries API, +# otherwise the agent falls back to a full sync. +ENTRIES=$(jq -n '{ + entries: [ + ( + range(1; 512) | { + parent_id: "spiffe://domain.test/node", + spiffe_id: ("spiffe://domain.test/workload" + (. | tostring)), + selectors: [ + { + type: "unix", + value: ("uid:" + (. | tostring)) + } + ] + } + ) + ] +}') + + +docker compose exec -T spire-server /opt/spire/bin/spire-server entry create -data - <<< ${ENTRIES} + +log-debug "creating registration entry..." +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/node" \ + -spiffeID "spiffe://domain.test/theworkload" \ + -selector "unix:uid:0" + +check-synced-entry "spire-agent" "spiffe://domain.test/theworkload" diff --git a/test/integration/cassandra-suites/sync-authorized-entries/05-check-svid b/test/integration/cassandra-suites/sync-authorized-entries/05-check-svid new file mode 100755 index 0000000000..1eef411a2b --- /dev/null +++ b/test/integration/cassandra-suites/sync-authorized-entries/05-check-svid @@ -0,0 +1,5 @@ +#!/bin/bash + +log-info "checking X509-SVID..." +docker compose exec -T spire-agent \ + /opt/spire/bin/spire-agent api fetch x509 || fail-now "SVID check failed" diff --git a/test/integration/cassandra-suites/sync-authorized-entries/README.md b/test/integration/cassandra-suites/sync-authorized-entries/README.md new file mode 100644 index 0000000000..addb71baf5 --- /dev/null +++ b/test/integration/cassandra-suites/sync-authorized-entries/README.md @@ -0,0 +1,7 @@ +# sync-authorized-entries suite + +## Description + +This suite verifies that the agent can sync authorized entries using +the SyncAuthorizedEntries API. For this we need to have at least 500 +entries created to avoid falling back to using a full sync. diff --git a/test/integration/cassandra-suites/sync-authorized-entries/conf/agent/agent.conf b/test/integration/cassandra-suites/sync-authorized-entries/conf/agent/agent.conf new file mode 100644 index 0000000000..f18b9d2d2f --- /dev/null +++ b/test/integration/cassandra-suites/sync-authorized-entries/conf/agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" + + # The TOKEN is replaced with the actual token generated by SPIRE server + # during the test run. + join_token = "TOKEN" +} + +plugins { + NodeAttestor "join_token" { + plugin_data { + } + } + KeyManager "memory" { + plugin_data { + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/sync-authorized-entries/conf/server/server.conf b/test/integration/cassandra-suites/sync-authorized-entries/conf/server/server.conf new file mode 100644 index 0000000000..37908761c8 --- /dev/null +++ b/test/integration/cassandra-suites/sync-authorized-entries/conf/server/server.conf @@ -0,0 +1,45 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "join_token" { + plugin_data { + } + } + KeyManager "memory" { + plugin_data = {} + } +} diff --git a/test/integration/cassandra-suites/sync-authorized-entries/docker-compose.yaml b/test/integration/cassandra-suites/sync-authorized-entries/docker-compose.yaml new file mode 100644 index 0000000000..77867bc469 --- /dev/null +++ b/test/integration/cassandra-suites/sync-authorized-entries/docker-compose.yaml @@ -0,0 +1,17 @@ +services: + spire-server: + image: spire-server:latest-local + env_file: + - ../.env + volumes: + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + extra_hosts: + - "host.docker.internal:host-gateway" + spire-agent: + image: spire-agent:latest-local + env_file: + - ../.env + volumes: + - ./conf/agent:/opt/spire/conf/agent + command: ["-config", "/opt/spire/conf/agent/agent.conf"] diff --git a/test/integration/cassandra-suites/sync-authorized-entries/teardown b/test/integration/cassandra-suites/sync-authorized-entries/teardown new file mode 100755 index 0000000000..fabbf145ae --- /dev/null +++ b/test/integration/cassandra-suites/sync-authorized-entries/teardown @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/test-suite.md b/test/integration/cassandra-suites/test-suite.md new file mode 100644 index 0000000000..824af68666 --- /dev/null +++ b/test/integration/cassandra-suites/test-suite.md @@ -0,0 +1,47 @@ +# Integration Test Suite Stability +- [x]: admin-endpoints +- [x]: agent-cli +- [x]: datastore-cassandra +- [x]: debug-endpoints +- [x]: delegatedidentity +- [x]: downstream-endpoints +- [ ]: entries +- [x]: envoy-sds-v3 + - some build tweaks needed +- [x]: envoy-sds-v3-spiffe-auth + - just build tweaks needed + - Dockerfile in that dir is unused I think... can remove it? +- [ ]: events-based-entries -> entries +- [x]: evict-agent +- [x]: fetch-jwt-svids +- [x]: fetch-wit-svids +- [x]: fetch-x509-svids +- [x]: force-rotation-jwt-authority +- [x]: force-rotation-self-signed + - needs enhancements to docker image to pass locally +- [ ]: force-rotation-upstream-authority + - fails when using SQL and when using Cassandra, I suspect this is due to openssl version issues due to running tests on OS X +- [x]: ghostunnel-federation + - required build customizations, nothing else +- [x]: join-token +- [x]: k8s + - kubernetes probe delays required +- [x]: nested-rotation +- [x]: node-attestation +- [x]: node-re-attestation +- [ ]: oidc-discovery-provider +- [x]: rotation +- [x]: self-test +- [ ]: spire-server-cli + - needs enhancements to docker image to pass locally + - fails due to incorrect count, this is very serious and indicates potential data loss. +- [x]: svidstore +- [x]: sync-authorized-entries +- [ ]: upgrade +- [x]: upstream-authority-cert-manager + - required minor modifications to path checking in cert, likely due to openssl version disparity. + - standard kubernetes probe delays required. +- [x]: upstream-authority-ejbca + - standard kubernetes probe delays required. +- [x]: upstream-authority-vault + - standard kubernetes probe delays required. \ No newline at end of file diff --git a/test/integration/cassandra-suites/upgrade/00-setup b/test/integration/cassandra-suites/upgrade/00-setup new file mode 100755 index 0000000000..fb54f3d7b2 --- /dev/null +++ b/test/integration/cassandra-suites/upgrade/00-setup @@ -0,0 +1,78 @@ +#!/bin/bash + +mkdir -p shared/server-data +mkdir -p shared/agent-data +mkdir -p test/before-server-upgrade +mkdir -p test/after-server-upgrade +mkdir -p test/after-agent-upgrade + +"${ROOTDIR}/setup/x509pop/setup.sh" conf/server conf/agent + +make-service() { + local _registry=$1 + local _version=$2 +cat <> docker-compose.yaml + spire-server-${_version}: + container_name: spire-server-${_version} + image: ${_registry}spire-server:${_version} + environment: + - http_proxy= + - https_proxy= + - HTTP_PROXY= + - HTTPS_PROXY= + hostname: spire-server + user: "${UID}" + healthcheck: + # TODO: Use default socket path in 1.7.0 + test: ["CMD", "/opt/spire/bin/spire-server", "healthcheck", "-socketPath", "/opt/spire/data/server/socket/api.sock"] + interval: 1s + timeout: 3s + retries: 15 + networks: + our-network: + aliases: + - spire-server + volumes: + - ./shared/server-data:/opt/spire/data + - ./conf/server:/opt/spire/conf/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + spire-agent-${_version}: + container_name: spire-agent-${_version} + image: ${_registry}spire-agent:${_version} + hostname: spire-agent + environment: + - http_proxy= + - https_proxy= + - HTTP_PROXY= + - HTTPS_PROXY= + user: "${UID}" + healthcheck: + # TODO: Use default socket path in 1.7.0 + test: ["CMD", "/opt/spire/bin/spire-agent", "healthcheck", "-socketPath", "/opt/spire/data/agent/socket/api.sock"] + interval: 1s + timeout: 3s + retries: 15 + networks: + - our-network + volumes: + - ./shared/agent-data:/opt/spire/data + - ./conf/agent:/opt/spire/conf/agent + - ./test:/opt/test + command: ["-config", "/opt/spire/conf/agent/agent.conf"] +EOF +} + +# +# Create the docker-compose.yaml with a spire-server and spire-agent for each +# version we want to test against the latest +# +cat < docker-compose.yaml +networks: + our-network: {} +services: +EOF + +make-service "" latest-local +while read -r version; do + make-service ghcr.io/spiffe/ "${version}" +done < versions.txt diff --git a/test/integration/cassandra-suites/upgrade/01-run-upgrade-tests b/test/integration/cassandra-suites/upgrade/01-run-upgrade-tests new file mode 100755 index 0000000000..d51d3a1696 --- /dev/null +++ b/test/integration/cassandra-suites/upgrade/01-run-upgrade-tests @@ -0,0 +1,187 @@ +#!/bin/bash + +# TODO: in 1.1.0. Once we're no longer testing 0.12.0, we can and should fix +# this test and these commands to rely on the default socket path. We can't do +# it until then because 0.12.0 does not understand the new CLI flag on the +# server, and also doesn't make the socket directory like the agent (which +# gives a little needless friction using the new default, since we'd need +# something else to create the directory first). + +start-old-server() { + local _maxchecks=15 + local _interval=1 + log-info "bringing up $1 server..." + local ctr_name="spire-server-$1" + docker-up "${ctr_name}" + docker-wait-for-healthy "${ctr_name}" "${_maxchecks}" "${_interval}" +} + +bootstrap-agent() { + # TODO: Remove -socketPath argument in 1.7.0 and rely on the default socket path + docker compose exec -T "spire-server-$1" \ + /opt/spire/bin/spire-server bundle show \ + -socketPath /opt/spire/data/server/socket/api.sock > conf/agent/bootstrap.crt +} + +start-old-agent() { + local _maxchecks=15 + local _interval=1 + log-info "bringing up $1 agent..." + local ctr_name="spire-agent-$1" + docker-up "${ctr_name}" + docker-wait-for-healthy "${ctr_name}" "${_maxchecks}" "${_interval}" +} + +create-registration-entry() { + log-debug "creating registration entry..." + # TODO: Remove -socketPath argument in 1.7.0 and rely on the default socket path + docker compose exec -T "spire-server-$1" \ + /opt/spire/bin/spire-server entry create \ + -socketPath /opt/spire/data/server/socket/api.sock \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload" \ + -selector "unix:uid:${UID}" \ + -x509SVIDTTL 0 + + # Check at most 30 times (with one second in between) that the agent has + # successfully synced down the workload entry. + local _maxchecks=30 + local _checkinterval=1 + for ((i=1;i<=_maxchecks;i++)); do + log-info "checking for synced workload entry ($i of $_maxchecks max)..." + docker compose logs "spire-agent-$1" + if docker compose logs "spire-agent-$1" | grep "spiffe://domain.test/workload"; then + return + fi + sleep "${_checkinterval}" + done + fail-now "timed out waiting for agent to sync down entry" +} + +check-old-agent-svid() { + log-info "checking X509-SVID on $1 agent..." + docker compose exec -T "spire-agent-$1" \ + /opt/spire/bin/spire-agent api fetch x509 \ + -socketPath /opt/spire/data/agent/socket/api.sock \ + -write /opt/test/before-server-upgrade || fail-now "SVID check failed" +} + +upgrade-server() { + local _maxchecks=15 + local _interval=1 + log-info "upgrading $1 server to latest..." + docker-stop "spire-server-$1" + local new_ctr_name="spire-server-latest-local" + docker-up "${new_ctr_name}" + docker-wait-for-healthy "${new_ctr_name}" "${_maxchecks}" "${_interval}" + check-codebase-version-is-ahead "$1" +} + +# Validates that the current version of the codebase is ahead of the version +# being updated. +check-codebase-version-is-ahead() { + _current_version=$(docker compose exec -T spire-server-latest-local \ + /opt/spire/bin/spire-server --version 2>&1 | cut -d'-' -f 1) + + if [ "$_current_version" = "$1" ]; then + fail-now "running upgrade test against the same version ($1)" + fi + + if [ $(printf '%s\n' "$_current_version" "$1" | sort -V | head -n1) = $_current_version ]; then + fail-now "the current server version ($_current_version) is lower than the version that is being updated ($1)" + fi +} + +check-old-agent-svid-after-upgrade() { + local _maxchecks=15 + local _checkinterval=3 + + for ((i=1;i<=_maxchecks;i++)); do + log-info "checking X509-SVID after server upgrade ($i of $_maxchecks max)..." + # TODO: Remove -socketPath argument in 1.7.0 and rely on the default socket path + docker compose exec -T "spire-agent-$1" \ + /opt/spire/bin/spire-agent api fetch x509 \ + -socketPath /opt/spire/data/agent/socket/api.sock \ + -write /opt/test/after-server-upgrade || fail-now "SVID check failed" + if ! cmp --silent svids/before-server-upgrade/svid.0.pem svids/after-server-upgrade/svid.0.pem; then + # SVID has rotated + return + fi + sleep "${_checkinterval}" + done + fail-now "timed out waiting for the SVID to rotate after upgrading the server" +} + +upgrade-agent() { + local _maxchecks=15 + local _interval=1 + log-info "upgrading $1 agent to latest..." + docker-stop "spire-agent-$1" + local new_ctr_name="spire-agent-latest-local" + docker-up "${new_ctr_name}" + docker-wait-for-healthy "${new_ctr_name}" "${_maxchecks}" "${_interval}" +} + +stop-and-evict-agent() { + log-info "stopping $1 agent..." + docker-stop "spire-agent-$1" + + log-info "evicting agent..." + # TODO: Remove -socketPath argument in 1.7.0 and rely on the default socket path + docker compose exec -T "spire-server-$1" \ + /opt/spire/bin/spire-server agent evict \ + -socketPath /opt/spire/data/server/socket/api.sock \ + -spiffeID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" + + rm -rf shared/agent-data/* +} + +check-new-agent-svid-after-upgrade() { + log-info "checking X509-SVID after agent upgrade..." + # TODO: Remove -socketPath argument in 1.7.0 and rely on the default socket path + docker compose exec -T spire-agent-latest-local \ + /opt/spire/bin/spire-agent api fetch x509 \ + -socketPath /opt/spire/data/agent/socket/api.sock \ + -write /opt/test/after-agent-upgrade || fail-now "SVID check failed" + + # SVIDs are cached in agent memory only. As the agent was restarted, there + # is no reason to believe that the SVID should compare the same. We'll do + # the comparison anyway as a sanity check. + if cmp --silent svids/after-server-upgrade/svid.0.pem svids/after-agent-upgrade/svid.0.pem; then + fail-now "SVID comparison failed unexpectedly after agent restart" + fi +} + +_versions=$(cat versions.txt) +for _version in ${_versions}; do + log-info "performing upgrade test for SPIRE ${_version}..." + + # clean up data and dumped SVIDs + rm -rf shared/server-data/* + rm -rf shared/agent-data/* + rm -f svids/before-server-upgrade/* + rm -f svids/after-server-upgrade/* + rm -f svids/after-agent-upgrade/* + + # test old agent attestation against old server + start-old-server "${_version}" + bootstrap-agent "${_version}" + start-old-agent "${_version}" + create-registration-entry "${_version}" + check-old-agent-svid "${_version}" + + # test server and agent upgrade + upgrade-server "${_version}" + check-old-agent-svid-after-upgrade "${_version}" + upgrade-agent "${_version}" + check-new-agent-svid-after-upgrade + + # test old agent attestation against new server + stop-and-evict-agent "latest-local" + bootstrap-agent "latest-local" + start-old-agent "${_version}" + check-old-agent-svid "${_version}" + + # bring everything down between versions + docker-down +done diff --git a/test/integration/cassandra-suites/upgrade/02-verify-codebase-version-is-updated b/test/integration/cassandra-suites/upgrade/02-verify-codebase-version-is-updated new file mode 100755 index 0000000000..ce06d9386d --- /dev/null +++ b/test/integration/cassandra-suites/upgrade/02-verify-codebase-version-is-updated @@ -0,0 +1,72 @@ +#!/bin/bash + +git="git --git-dir ${REPODIR}/.git" + +check-version-against-latest-release() { + _commit_version="$1" + _default_branch="origin/$($git remote show origin | grep 'HEAD branch' | cut -d":" -f2 | xargs)" + _tracking_branch=$($git for-each-ref --format='%(upstream:short)' "$($git symbolic-ref -q HEAD)") + + # Determine which branch to detect the "latest" version from: + # - for PRs, this will be the branch the PR targets (as supplied via + # CICD_TARGET_BRANCH by the CI/CD pipeline). + # - for non-PRs from a local branch with a tracking branch, we'll use + # the tracking branch (e.g. local development branch tracking main) + # - for non-PRs from a local branch without a tracking branch, we'll fail + # the test, since it isn't clear which version we should be tracking. + _version_from_branch= + if [ -n "${CICD_TARGET_BRANCH}" ]; then + _version_from_branch="origin/${CICD_TARGET_BRANCH}" + log-info "target branch (explicit): ${_version_from_branch}" + elif [ -n "${_tracking_branch}" ]; then + _version_from_branch="${_tracking_branch}" + log-info "target branch (tracking): ${_version_from_branch}" + else + fail-now "unable to determine latest version; either the CICD_TARGET_BRANCH envvar or an upstream tracking branch needs to be set" + fi + + if [ "${_version_from_branch}" = "${_default_branch}" ]; then + # The default branch should use the latest release tag from the repo + _latest_version=$($git tag --list 'v*' --sort -version:refname | head -n1 | cut -c 2-) + log-info "latest release: ${_latest_version}" + else + # Non-default branches should have aligned version with the latest + # release from that branch. So we'll scan for the latest tag. + _latest_version=$($git describe --match "v*" --abbrev=0 "${_version_from_branch}"| cut -c 2-) + log-info "latest release from ${_version_from_branch}: ${_latest_version}" + fi + + log-info "commit version: ${_commit_version}" + + if [ "${_commit_version}" == "${_latest_version}" ]; then + fail-now "commit version (${_commit_version}) must be greater than the latest release in this branch (${_latest_version}); has the version been bumped?" + elif [ "$(printf '%s\n%s' "${_latest_version}" "${_commit_version}" | sort -V -r | head -n1)" != "${_commit_version}" ]; then + fail-now "commit version (${_commit_version}) must be greater than the latest release in this branch (${_latest_version}); has the version been bumped?" + fi +} + +# Get current version from latest local image +docker-up spire-server-latest-local +_commit_version=$(docker compose exec -T spire-server-latest-local \ + /opt/spire/bin/spire-server --version 2>&1 | cut -d'-' -f 1) +docker-down + +# Get tag of the current commit +_current_tag=$($git describe --exact-match HEAD --match "v*" 2> /dev/null | cut -c 2- || true) + +case "${_current_tag}" in + + "${_commit_version}") + log-info "current commit is a tagged commit and has the correct version (${_commit_version})" + ;; + + "") + log-info "current commit is not tagged; checking against the latest release in the target branch" + check-version-against-latest-release "${_commit_version}" + ;; + + *) + fail-now "current commit version (${_commit_version}) does not match the commit tag (${_current_tag})" + ;; + +esac diff --git a/test/integration/cassandra-suites/upgrade/README.md b/test/integration/cassandra-suites/upgrade/README.md new file mode 100644 index 0000000000..567576b0c9 --- /dev/null +++ b/test/integration/cassandra-suites/upgrade/README.md @@ -0,0 +1,43 @@ +# Upgrade Suite + +## Description + +This suite tests a simple upgrade step from SPIRE from one version to the next. + +It does the following in order: + +1. Brings up the _old_ SPIRE server and agent +1. Obtains an SVID from the _old_ agent +1. Upgrades the SPIRE server +1. Obtains an SVID from the _old_ agent (making sure it has rotated) +1. Upgrades the SPIRE agent +1. Obtains an SVID from the _new_ agent (making sure it has rotated) + +### Upgrading SPIRE Server/Agent + +The _upgrade_ is performed by bringing down the container running the _old_ +version and starting the container running the _new_ version. The containers +share configuration and data directory via a series of shared volumes. + +### Checking for rotation + +To check for rotation, the SVID is written to disk at each step. It is then +checked against the SVID for the previous step to make sure it has been +rotated. + +### Maintenance + +When making a SPIRE release, the versions.txt should be updated to add the new +version, ideally as part of the first commit after release that bumps the base +version in pkg/common/version/version.go. + +When preparing to release a new "major" release (_minor_ release pre-1.0), the +versions.txt file should be updated to remove the "major"-2 versions, since we +only support upgrading from one "major" build to the next. For example, if the +versions.txt file contained all 0.8.x and 0.9.x versions, the 0.8.x versions +should be removed as part of the 0.10.0 release. + +## Future considerations + +- Provide additional "+/- 1" SPIRE compatibility checks, as currently we only + test that the SPIRE components start up and that SVIDs rotate. diff --git a/test/integration/cassandra-suites/upgrade/conf/agent/agent.conf b/test/integration/cassandra-suites/upgrade/conf/agent/agent.conf new file mode 100644 index 0000000000..a30c89f0ad --- /dev/null +++ b/test/integration/cassandra-suites/upgrade/conf/agent/agent.conf @@ -0,0 +1,27 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + socket_path ="/opt/spire/data/agent/socket/api.sock" # TODO: Use default socket path in 1.7.0 + trust_bundle_path = "/opt/spire/conf/agent/bootstrap.crt" + trust_domain = "domain.test" +} + +plugins { + NodeAttestor "x509pop" { + plugin_data { + private_key_path = "/opt/spire/conf/agent/agent.key.pem" + certificate_path = "/opt/spire/conf/agent/agent.crt.pem" + } + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "unix" { + plugin_data { + } + } +} diff --git a/test/integration/cassandra-suites/upgrade/conf/server/server.conf b/test/integration/cassandra-suites/upgrade/conf/server/server.conf new file mode 100644 index 0000000000..2bdeef86a0 --- /dev/null +++ b/test/integration/cassandra-suites/upgrade/conf/server/server.conf @@ -0,0 +1,51 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "domain.test" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" + ca_ttl = "1m" + default_x509_svid_ttl = "15s" + socket_path = "/opt/spire/data/server/socket/api.sock" # TODO: Remove this in 1.7.0 and rely on the default socket path + + experimental { + allow_pluggable_datastore = true + } +} + +plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["host.docker.internal:9044", "host.docker.internal:9045", "host.docker.internal:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + NodeAttestor "x509pop" { + plugin_data { + ca_bundle_path = "/opt/spire/conf/server/agent-cacert.pem" + } + } + KeyManager "disk" { + plugin_data = { + keys_path = "/opt/spire/data/server/keys.json" + } + } +} diff --git a/test/integration/cassandra-suites/upgrade/teardown b/test/integration/cassandra-suites/upgrade/teardown new file mode 100755 index 0000000000..2e181faa53 --- /dev/null +++ b/test/integration/cassandra-suites/upgrade/teardown @@ -0,0 +1,5 @@ +#!/bin/bash +if [ -z "$SUCCESS" ]; then + docker compose logs +fi +docker-down diff --git a/test/integration/cassandra-suites/upgrade/versions.txt b/test/integration/cassandra-suites/upgrade/versions.txt new file mode 100644 index 0000000000..e45f9ea55c --- /dev/null +++ b/test/integration/cassandra-suites/upgrade/versions.txt @@ -0,0 +1,6 @@ +1.13.0 +1.13.1 +1.13.2 +1.13.3 +1.14.0 +1.14.1 diff --git a/test/integration/cassandra-suites/upstream-authority-cert-manager/00-setup-kind b/test/integration/cassandra-suites/upstream-authority-cert-manager/00-setup-kind new file mode 100755 index 0000000000..dd9b06d897 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-cert-manager/00-setup-kind @@ -0,0 +1,24 @@ +#!/bin/bash + +# Create a temporary path that will be added to the PATH to avoid picking up +# binaries from the environment that aren't a version match. +mkdir -p ./bin + +KIND_PATH=./bin/kind +KUBECTL_PATH=./bin/kubectl + +# Download kind at the expected version at the given path. +download-kind "${KIND_PATH}" + +# Download kubectl at the expected version. +download-kubectl "${KUBECTL_PATH}" + +# Start the kind cluster. +start-kind-cluster "${KIND_PATH}" cert-manager-test ./conf/kind-config.yaml + +# Load the given images in the cluster. +container_images=("spire-server:latest-local") +load-images "${KIND_PATH}" cert-manager-test "${container_images[@]}" + +# Set the kubectl context. +set-kubectl-context "${KUBECTL_PATH}" kind-cert-manager-test diff --git a/test/integration/cassandra-suites/upstream-authority-cert-manager/01-setup-cert-manager b/test/integration/cassandra-suites/upstream-authority-cert-manager/01-setup-cert-manager new file mode 100755 index 0000000000..7c4d2302d8 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-cert-manager/01-setup-cert-manager @@ -0,0 +1,30 @@ +#!/bin/bash + +source init-kubectl + + +CERTMANAGERVERSION=v1.3.1 +CERTMANAGERURL="https://github.com/jetstack/cert-manager/releases/download/$CERTMANAGERVERSION/cert-manager.yaml" + + +log-info "installing cert-manager..." +./bin/kubectl apply -f $CERTMANAGERURL +./bin/kubectl rollout status deploy -n cert-manager cert-manager +./bin/kubectl rollout status deploy -n cert-manager cert-manager-cainjector +./bin/kubectl rollout status deploy -n cert-manager cert-manager-webhook + +apply_cert-manager_manifests() { + MAXROLLOUTCHECKS=12 + ROLLOUTCHECKINTERVAL=15s + for ((i=0; i<${MAXROLLOUTCHECKS}; i++)); do + if ./bin/kubectl apply -f ./conf/cert-manager-issuer.yaml; then + return + fi + log-warn "cert-manager not ready" && sleep 5 + done + + fail-now "Failed to deploy cert-manager and bootstrap manifests in time" +} + +log-info "creating cert-manager Issuer resources..." +apply_cert-manager_manifests diff --git a/test/integration/cassandra-suites/upstream-authority-cert-manager/02-deploy-spire b/test/integration/cassandra-suites/upstream-authority-cert-manager/02-deploy-spire new file mode 100755 index 0000000000..71def37449 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-cert-manager/02-deploy-spire @@ -0,0 +1,24 @@ +#!/bin/bash + +source init-kubectl + +wait-for-rollout() { + ns=$1 + obj=$2 + MAXROLLOUTCHECKS=12 + ROLLOUTCHECKINTERVAL=15s + for ((i=0; i<${MAXROLLOUTCHECKS}; i++)); do + log-info "checking rollout status for ${ns} ${obj}..." + if ./bin/kubectl "-n${ns}" rollout status "$obj" --timeout="${ROLLOUTCHECKINTERVAL}"; then + return + fi + log-warn "describing ${ns} ${obj}..." + ./bin/kubectl "-n${ns}" describe "$obj" || true + log-warn "logs for ${ns} ${obj}..." + ./bin/kubectl "-n${ns}" logs --all-containers "$obj" || true + done + fail-now "Failed waiting for ${obj} to roll out." +} + +./bin/kubectl apply -k ./conf/server +wait-for-rollout spire deployment/spire-server diff --git a/test/integration/cassandra-suites/upstream-authority-cert-manager/03-verify-ca b/test/integration/cassandra-suites/upstream-authority-cert-manager/03-verify-ca new file mode 100755 index 0000000000..dd61027afe --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-cert-manager/03-verify-ca @@ -0,0 +1,57 @@ +#!/bin/bash + +source init-kubectl + +expLeafIssuerOpenSSL="issuer=C=US, O=SPIFFE, SerialNumber=[[:digit:]]+" +expCASubjectOpenSSL="subject=O=cert-manager.io, CN=example.org" + +# On macOS, /usr/bin/openssl is LibreSSL, which outputs certificate details with a different format than OpenSSL +expLeafIssuerLibreSSL="issuer= /C=US/O=SPIFFE" +expCASubjectLibreSSL="subject= /O=cert-manager.io/CN=example.org" + +expLeafURI="URI:spiffe://example.org/ns/foo/sa/bar" + +log-debug "verifying CA..." + +mintx509svid_out=mintx509svid-out.txt +./bin/kubectl exec -n spire $(./bin/kubectl get pod -n spire -o name) -- /opt/spire/bin/spire-server x509 mint -spiffeID spiffe://example.org/ns/foo/sa/bar > $mintx509svid_out + +svid=svid.pem +sed -n '/-----BEGIN CERTIFICATE-----/,/^$/{/^$/q; p;}' $mintx509svid_out > $svid + +bundle=bundle.pem +sed -n '/Root CAs:/,/^$/p' $mintx509svid_out | sed -n '/-----BEGIN CERTIFICATE-----/,/^$/{/^$/q; p;}' > $bundle + +leafURIResult=$(openssl x509 -noout -text -in $svid | grep URI | sed 's/^ *//g') +leafIssuerResult=$(openssl x509 -noout -issuer -in $svid) +caSubjectResult=$(openssl x509 -noout -subject -in $bundle) + +if [ $(openssl version | awk '{print $1}') == 'LibreSSL' ]; then + expLeafIssuer=$expLeafIssuerLibreSSL + expCASubject=$expCASubjectLibreSSL +else + expLeafIssuer=$expLeafIssuerOpenSSL + expCASubject=$expCASubjectOpenSSL +fi + +if [ "$leafURIResult" != "$expLeafURI" ]; then + fail-now "unexpected SPIFFE ID in resulting certificate, exp=$expLeafURI got=$leafURIResult" +fi +log-info "got expected SPIFFE ID result" + +if [ ! "$leafIssuerResult" != "$expLeafIssuer" ]; then + fail-now "unexpected Issuer in resulting certificate, exp=$expLeafIssuer got=$leafIssuerResult" +fi +log-info "got expected Issuer result" + +if [ "$caSubjectResult" != "$expCASubject" ]; then + fail-now "unexpected Subject in resulting CA bundle, exp=$expCASubject got=$caSubjectResult" +fi +log-info "got expected CA bundle result" + +log-debug "ensuring CertificateRequest has been cleaned-up" +exitingRequests=$(./bin/kubectl get cr -n spire --selector="cert-manager.spiffe.io/trust-domain==example.org" -oname | wc -l) +if [ "$exitingRequests" -ne 0 ]; then + ./bin/kubectl get cr -n spire --selector="cert-manager.spiffe.io/trust-domain==example.org" -oname + fail-now "expected CertificateRequest to be cleaned-up, got=$exitingRequests" +fi diff --git a/test/integration/cassandra-suites/upstream-authority-cert-manager/README.md b/test/integration/cassandra-suites/upstream-authority-cert-manager/README.md new file mode 100644 index 0000000000..efc3b55257 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-cert-manager/README.md @@ -0,0 +1,14 @@ +# Upstream Authority cert-manager Suite + +## Description + +This suite sets up a Kubernetes cluster using [Kind](https://kind.sigs.k8s.io), +installs cert-manager and a self-signed CA Issuer. It then asserts the +following: + +* SPIRE server successfully requests an intermediate CA from the referenced + cert-manager Issuer +* Verifies that obtained identities have been signed by that intermediate CA, + and the cert-manager Issuer is the root of trust +* Verifies that the SPIRE server will delete stale CertificateRequests that it + is responsible for diff --git a/test/integration/cassandra-suites/upstream-authority-cert-manager/conf/cert-manager-issuer.yaml b/test/integration/cassandra-suites/upstream-authority-cert-manager/conf/cert-manager-issuer.yaml new file mode 100644 index 0000000000..e767427f1d --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-cert-manager/conf/cert-manager-issuer.yaml @@ -0,0 +1,41 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: spire +--- +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: selfsigned + namespace: spire +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: spire-ca + namespace: spire +spec: + commonName: example.org + secretName: spire-ca + subject: + organizations: + - cert-manager.io + duration: 2160h + isCA: true + privateKey: + algorithm: ECDSA + size: 256 + issuerRef: + name: selfsigned + kind: Issuer +--- +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: spire-ca + namespace: spire +spec: + ca: + secretName: spire-ca diff --git a/test/integration/cassandra-suites/upstream-authority-cert-manager/conf/kind-config.yaml b/test/integration/cassandra-suites/upstream-authority-cert-manager/conf/kind-config.yaml new file mode 100644 index 0000000000..3492b37a45 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-cert-manager/conf/kind-config.yaml @@ -0,0 +1,8 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + image: kindest/node:v1.34.1 + extraMounts: + - hostPath: /usr/local/share/ca-certificates + containerPath: /usr/local/share/ca-certificates diff --git a/test/integration/cassandra-suites/upstream-authority-cert-manager/conf/server/kustomization.yaml b/test/integration/cassandra-suites/upstream-authority-cert-manager/conf/server/kustomization.yaml new file mode 100644 index 0000000000..61ec1abdc4 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-cert-manager/conf/server/kustomization.yaml @@ -0,0 +1,10 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# list of Resource Config to be Applied +resources: +- spire-server.yaml + +# namespace to deploy all Resources to +namespace: spire diff --git a/test/integration/cassandra-suites/upstream-authority-cert-manager/conf/server/spire-server.yaml b/test/integration/cassandra-suites/upstream-authority-cert-manager/conf/server/spire-server.yaml new file mode 100644 index 0000000000..63e0a599f7 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-cert-manager/conf/server/spire-server.yaml @@ -0,0 +1,168 @@ +# ServiceAccount used by the SPIRE server. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: spire-server + namespace: spire + +--- + +# Role for the SPIRE server +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-role + namespace: spire +rules: + # allow creation of cert-manager CertificateRequest resources, as well as deletion for cleaning-up +- apiGroups: ["cert-manager.io"] + resources: ["certificaterequests"] + verbs: ["get", "create", "delete", "list"] + +--- + +# RoleBinding granting the spire-server-role to the SPIRE server +# service account. +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-role-binding + namespace: spire +subjects: +- kind: ServiceAccount + name: spire-server + namespace: spire +roleRef: + kind: Role + name: spire-server-role + apiGroup: rbac.authorization.k8s.io + +--- + +# ConfigMap containing the SPIRE server configuration. +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + + experimental { + allow_pluggable_datastore = true + } + } + + plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["172.17.0.1:9044", "172.17.0.1:9045", "172.17.0.1:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + + KeyManager "memory" { + plugin_data = {} + } + + UpstreamAuthority "cert-manager" { + plugin_data = { + namespace = "spire" + issuer_name = "spire-ca" + issuer_kind = "Issuer" + issuer_group = "cert-manager.io" + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } + +--- + +# This is the Deployment for the SPIRE server. It waits for SPIRE database to +# initialize and uses the SPIRE healthcheck command for liveness/readiness +# probes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spire-server + namespace: spire + labels: + app: spire-server +spec: + replicas: 1 + selector: + matchLabels: + app: spire-server + template: + metadata: + namespace: spire + labels: + app: spire-server + spec: + serviceAccountName: spire-server + shareProcessNamespace: true + containers: + - name: spire-server + image: spire-server:latest-local + imagePullPolicy: Never + args: ["-config", "/run/spire/config/server.conf"] + ports: + - containerPort: 8081 + volumeMounts: + - name: spire-config + mountPath: /run/spire/config + readOnly: true + livenessProbe: + httpGet: + path: /live + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 5 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 5 + volumes: + - name: spire-config + configMap: + name: spire-server diff --git a/test/integration/cassandra-suites/upstream-authority-cert-manager/init-kubectl b/test/integration/cassandra-suites/upstream-authority-cert-manager/init-kubectl new file mode 100644 index 0000000000..7e28cce24b --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-cert-manager/init-kubectl @@ -0,0 +1,9 @@ +#!/bin/bash + +KUBECONFIG="${RUNDIR}/kubeconfig" +if [ ! -f "${RUNDIR}/kubeconfig" ]; then + ./bin/kind get kubeconfig --name=cert-manager-test > "${RUNDIR}/kubeconfig" + ./bin/kind get kubeconfig --name=cert-manager-test > "conf/server/kubeconfig" +fi +export KUBECONFIG + diff --git a/test/integration/cassandra-suites/upstream-authority-cert-manager/teardown b/test/integration/cassandra-suites/upstream-authority-cert-manager/teardown new file mode 100755 index 0000000000..eb5b4c2f57 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-cert-manager/teardown @@ -0,0 +1,11 @@ +#!/bin/bash + +source init-kubectl + +if [ -z "$SUCCESS" ]; then + ./bin/kubectl logs -n spire -l app=spire-server +fi + +export KUBECONFIG= + +./bin/kind delete cluster --name cert-manager-test diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/00-setup-kind b/test/integration/cassandra-suites/upstream-authority-ejbca/00-setup-kind new file mode 100755 index 0000000000..db775541ee --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/00-setup-kind @@ -0,0 +1,28 @@ +#!/bin/bash + +# Create a temporary path that will be added to the PATH to avoid picking up +# binaries from the environment that aren't a version match. +mkdir -p ./bin + +KIND_PATH=./bin/kind +KUBECTL_PATH=./bin/kubectl +HELM_PATH=./bin/helm + +# Download kind at the expected version at the given path. +download-kind "${KIND_PATH}" + +# Download kubectl at the expected version. +download-kubectl "${KUBECTL_PATH}" + +# Download helm at the expected version. +download-helm "${HELM_PATH}" + +# Start the kind cluster. +start-kind-cluster "${KIND_PATH}" ejbca-test ./conf/kind-config.yaml + +# Load the given images in the cluster. +container_images=("spire-server:latest-local") +load-images "${KIND_PATH}" ejbca-test "${container_images[@]}" + +# Set the kubectl context. +set-kubectl-context "${KUBECTL_PATH}" kind-ejbca-test diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/01-setup-ejbca b/test/integration/cassandra-suites/upstream-authority-ejbca/01-setup-ejbca new file mode 100755 index 0000000000..d595ba7205 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/01-setup-ejbca @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e -o pipefail +source init-kubectl + +log-info "installing ejbca..." + +EJBCA_NAMESPACE="ejbca" +EJBCA_MTLS_SECRET_NAME="superadmin-tls" +EJBCA_SUBCA_SECRET_NAME="subca" + +cd conf +./deploy.sh --ejbca-namespace "$EJBCA_NAMESPACE" --superadmin-secret-name "$EJBCA_MTLS_SECRET_NAME" --subca-secret-name "$EJBCA_SUBCA_SECRET_NAME" +cd .. diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/02-deploy-spire b/test/integration/cassandra-suites/upstream-authority-ejbca/02-deploy-spire new file mode 100755 index 0000000000..d27c6871e1 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/02-deploy-spire @@ -0,0 +1,24 @@ +#!/bin/bash + +set -e -o pipefail +source init-kubectl + +EJBCA_NAMESPACE="ejbca" +EJBCA_MTLS_SECRET_NAME="superadmin-tls" +EJBCA_SUBCA_SECRET_NAME="subca" + +log-info "installing spire..." +./bin/kubectl create namespace spire + +secrets=( + "$EJBCA_MTLS_SECRET_NAME" + "$EJBCA_SUBCA_SECRET_NAME" +) +for secret in "${secrets[@]}"; do + ./bin/kubectl --namespace "$EJBCA_NAMESPACE" get secret "$secret" -o yaml \ + | sed 's/namespace: .*/namespace: spire/' \ + | ./bin/kubectl apply -f - +done + +./bin/kubectl -n spire apply -k conf/server +./bin/kubectl wait pods -n spire -l app=spire-server --for=condition=Ready --timeout=120s diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/03-verify-ca b/test/integration/cassandra-suites/upstream-authority-ejbca/03-verify-ca new file mode 100755 index 0000000000..535b3b2d11 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/03-verify-ca @@ -0,0 +1,118 @@ +#!/bin/bash + +set -e -o pipefail +source init-kubectl + +EJBCA_NAMESPACE="ejbca" +EJBCA_SUBCA_SECRET_NAME="subca" + +log-debug "verifying CA..." + +cert_start="-----BEGIN CERTIFICATE-----" +cert_end="-----END CERTIFICATE-----" + +# First, collect the CA chain from the K8s secret created by the EJCBA +# deployment script. We expect this secret to have the full chain up to the root. + +i=0 +while read -r line; do + if [[ "$line" == "$cert_start" ]]; then + cert="$line"$'\n' + in_cert=1 + elif [[ "$line" == "$cert_end" ]]; then + cert+="$line"$'\n' + chain[i]=$(echo "$cert") + i=$((i + 1)) + in_cert=0 + elif [[ $in_cert -eq 1 ]]; then + cert+="$line"$'\n' + fi +done < <(./bin/kubectl --namespace "$EJBCA_NAMESPACE" get secret "$EJBCA_SUBCA_SECRET_NAME" -o jsonpath='{.data.ca\.crt}' | base64 -d) + +log-debug "the issuing ca in EJBCA has a chain length of ${#chain[@]} certificates (including the root)" + +# Second, mint an x509 SVID from the SPIRE server and collect them into an array. +# +# The contents of mintx509svid_out should have the following format: +# +# X509-SVID: +# +# +# +# +# Private key: +# +# +# Root CAs: +# + +# So, the contents of `certs` should be the entire certificate chain, starting +# with the x509 svid at index 0, up to the root CA at index i. + +i=0 +while read -r line; do + if [[ "$line" == "$cert_start" ]]; then + cert="$line"$'\n' + in_cert=1 + elif [[ "$line" == "$cert_end" ]]; then + cert+="$line"$'\n' + certs[i]=$(echo "$cert") + i=$((i + 1)) + in_cert=0 + elif [[ $in_cert -eq 1 ]]; then + cert+="$line"$'\n' + fi +done < <(./bin/kubectl exec -n spire $(./bin/kubectl get pod -n spire -o name) -- /opt/spire/bin/spire-server x509 mint -spiffeID spiffe://example.org/ns/foo/sa/bar) + +log-debug "the x509 svid has a chain length of ${#certs[@]} certificates (including the svid and root)" + +# Verify that the SPIRE server is using the EJBCA UpstreamAuthority by comparing the CA chain + +log-debug "verifying that the intermediate ca(s) and root ca from the svid are the EJBCA issuing ca/intermediates and root ca" + +i=0 +while [[ $i -lt ${#chain[@]} ]]; do + expected_hash=$(echo "${chain[$i]}" | openssl x509 -noout -modulus | openssl sha256 | awk '{print $2}') + + corresponding_certs_index=$((${#certs[@]} - ${#chain[@]} + i)) + actual_hash=$(echo "${certs[$corresponding_certs_index]}" | openssl x509 -noout -modulus | openssl sha256 | awk '{print $2}') + if [[ "$expected_hash" != "$actual_hash" ]]; then + fail-now "ca chain verification failed: expected modulus to have hash $expected_hash, got $actual_hash (cert $((i+1))/${#chain[@]})" + fi + i=$((i + 1)) +done + +log-debug "verifying that the x509 svid was signed by the spire intermediate ca, and that the spire intermediate ca has a valid chain up to the root ca in EJBCA" + +# We use -untrusted since none of the intermediates are trusted roots - IE, verify the whole chain +# Also, we verify against the CA chain from EJBCA to make extra sure that the SVID was signed by the correct CA +# We trust SPIRE to build a valid certificate chain, but we want to make sure that the SVID is part of the correct PKI. + +root_ca=("${chain[@]:((${#chain[@]} - 1)):1}") +full_chain=("${certs[1]}" "${chain[@]:0:${#chain[@]}-1}") + +# SPIRE requested the second certificate in certs +if ! openssl verify -CAfile <(printf "%s\n" "${root_ca[@]}") \ + -untrusted <(printf "%s\n" "${full_chain[@]}") \ + <(echo "${certs[0]}"); +then + fail-now "x509 svid verification failed: failed to verify the x509 svid up to the root ca in EJBCA" +fi + +log-debug "verifying that the x509 svid has the expected uri san" + +# Make sure that the x509 SVID has the correct URI +expectedURI="URI:spiffe://example.org/ns/foo/sa/bar" +actualURI=$(openssl x509 -noout -text -in <(echo "${certs[0]}") | grep URI | sed 's/^ *//g') +if [[ "$expectedURI" != "$actualURI" ]]; then + fail-now "x509 svid verification failed: expected URI to be $expectedURI, got $actualURI" +fi + +log-debug "verifying that the intermediate ca issued by EJBCA has the expected uri san" + +# Make sure that the intermediate CA has the correct URI +expectedURI="URI:spiffe://example.org" +actualURI=$(openssl x509 -noout -text -in <(echo "${certs[1]}") | grep URI | sed 's/^ *//g') +if [[ "$expectedURI" != "$actualURI" ]]; then + fail-now "x509 svid verification failed: expected URI to be $expectedURI, got $actualURI" +fi diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/README.md b/test/integration/cassandra-suites/upstream-authority-ejbca/README.md new file mode 100644 index 0000000000..90654bd505 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/README.md @@ -0,0 +1,8 @@ +# Upstream Authority ejbca Suite + +## Description + +This suite sets up a single node Kubernetes cluster using [Kind](https://kind.sigs.k8s.io), deploys and configures EJBCA Community, and then asserts the following: + +1. SPIRE Server successfully requests an intermediate CA from EJBCA. +2. Verifies that workload x509s have been signed by that intermediate CA, and that EJBCA is the root of trust. diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/deploy.sh b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/deploy.sh new file mode 100755 index 0000000000..e12673a15e --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/deploy.sh @@ -0,0 +1,401 @@ +#!/bin/bash + +EJBCA_NAMESPACE=ejbca +EJBCA_IMAGE="keyfactor/ejbca-ce" +EJBCA_TAG="latest" + +IMAGE_PULL_SECRET_NAME="" + +EJBCA_SUPERADMIN_SECRET_NAME="superadmin-tls" +EJBCA_MANAGEMENTCA_SECRET_NAME="managementca" +EJBCA_SUBCA_SECRET_NAME="subca" + +EJBCA_ROOT_CA_NAME="Root-CA" +EJBCA_SUB_CA_NAME="Sub-CA" + +# Verify that required tools are installed +verifySupported() { + HAS_HELM="$(type "../bin/helm" &>/dev/null && echo true || echo false)" + HAS_KUBECTL="$(type "../bin/kubectl" &>/dev/null && echo true || echo false)" + HAS_JQ="$(type "jq" &>/dev/null && echo true || echo false)" + HAS_CURL="$(type "curl" &>/dev/null && echo true || echo false)" + HAS_OPENSSL="$(type "openssl" &>/dev/null && echo true || echo false)" + + if [ "${HAS_JQ}" != "true" ]; then + echo "jq is required" + exit 1 + fi + + if [ "${HAS_CURL}" != "true" ]; then + echo "curl is required" + exit 1 + fi + + if [ "${HAS_HELM}" != "true" ]; then + echo "helm is required" + exit 1 + fi + + if [ "${HAS_KUBECTL}" != "true" ]; then + echo "kubectl is required" + exit 1 + fi + + if [ "${HAS_OPENSSL}" != "true" ]; then + echo "openssl is required" + exit 1 + fi +} + +############################################### +# EJBCA CA Creation and Initialization # +############################################### + +createConfigmapFromFile() { + local cluster_namespace=$1 + local configmap_name=$2 + local filepath=$3 + + if [ $(../bin/kubectl get configmap -n "$cluster_namespace" -o json | jq -c ".items | any(.[] | .metadata; .name == \"$configmap_name\")") == "false" ]; then + echo "Creating "$configmap_name" configmap" + ../bin/kubectl create configmap -n "$cluster_namespace" "$configmap_name" --from-file="$filepath" + else + echo "$configmap_name exists" + fi +} + +# Figure out if the cluster is already initialized for EJBCA +isEjbcaAlreadyDeployed() { + deployed=false + if [ ! "$(../bin/kubectl --namespace "$EJBCA_NAMESPACE" get pods -l app.kubernetes.io/name=ejbca -o json | jq '.items[] | select(.metadata.labels."app.kubernetes.io/name" == "ejbca") | .metadata.name' | tr -d '"')" != "" ]; then + echo "EJBCA is not deployed - EJBCA pod is not present" + return 1 + fi + + if [[ ! $(../bin/kubectl get secret --namespace "$EJBCA_NAMESPACE" -o json | jq --arg "name" "$EJBCA_SUPERADMIN_SECRET_NAME" -e '.items[] | select(.metadata.name == $name)') ]]; then + echo "EJBCA is not deployed - SuperAdmin secret is not present" + return 1 + fi + + if [[ ! $(../bin/kubectl get secret --namespace "$EJBCA_NAMESPACE" -o json | jq --arg "name" "$EJBCA_SUPERADMIN_SECRET_NAME" -e '.items[] | select(.metadata.name == $name)') ]]; then + echo "EJBCA is not deployed - ManagementCA secret is not present" + return 1 + fi + + if [[ ! $(../bin/kubectl get secret --namespace "$EJBCA_NAMESPACE" -o json | jq --arg "name" "$EJBCA_SUPERADMIN_SECRET_NAME" -e '.items[] | select(.metadata.name == $name)') ]]; then + echo "EJBCA is not deployed - SubCA secret is not present" + return 1 + fi + + return 0 +} + +certificate_exists() { + if [[ $(../bin/kubectl get certificate -o json | jq -r '.items.[] | select(.metadata.name == "ejbca-certificate")') == "" ]]; then + return 1 + else + return 0 + fi +} + +# Waits for the EJBCA node to be ready +# cluster_namespace - The namespace where the EJBCA node is running +# ejbca_pod_name - The name of the Pod running the EJBCA node +waitForEJBCANode() { + local cluster_namespace=$1 + local ejbca_pod_name=$2 + + echo "Waiting for EJBCA node to be ready" + until ! ../bin/kubectl -n "$cluster_namespace" exec "$ejbca_pod_name" -- /opt/keyfactor/bin/ejbca.sh 2>&1 | grep -q "could not contact EJBCA"; do + echo "EJBCA node not ready yet, retrying in 5 seconds..." + sleep 5 + done + echo "EJBCA node $cluster_namespace/$ejbca_pod_name is ready." +} + +configmapNameFromFilename() { + local filename=$1 + echo "$(basename "$filename" | tr _ - | tr '[:upper:]' '[:lower:]')" +} + +# Initialize the cluster for EJBCA +initClusterForEJBCA() { + # Create the EJBCA namespace if it doesn't already exist + if [ "$(../bin/kubectl get namespace -o json | jq -e '.items[] | select(.metadata.name == "'"$EJBCA_NAMESPACE"'") | .metadata.name')" == "" ]; then + ../bin/kubectl create namespace "$EJBCA_NAMESPACE" + fi + + # Mount the staged EEPs & CPs to Kubernetes with ConfigMaps + for file in $(find ./ejbca/staging -maxdepth 1 -mindepth 1); do + configmapname="$(basename "$file")" + createConfigmapFromFile "$EJBCA_NAMESPACE" "$(configmapNameFromFilename "$configmapname")" "$file" + done + + # Mount the ejbca init script to Kubernetes using a ConigMap + createConfigmapFromFile "$EJBCA_NAMESPACE" "ejbca-init" "./ejbca/scripts/ejbca-init.sh" +} + +# Clean up the config maps used to init the EJBCA database +cleanupEJBCAConfigMaps() { + for file in $(find ./ejbca/staging -maxdepth 1 -mindepth 1); do + configMapName="$(configmapNameFromFilename "$file")" + ../bin/kubectl delete configmap --namespace "$EJBCA_NAMESPACE" "$configMapName" + done +} + +# Initialze the database by spinning up an instance of EJBCA infront of a MariaDB database, and +# create the CA hierarchy and import boilerplate profiles. +initEJBCADatabase() { + helm_install_args=( + "--namespace" + "$EJBCA_NAMESPACE" + "install" + "ejbca-test" + "./ejbca" + "--set" "ejbca.ingress.enabled=false" + ) + + container_staging_dir="/opt/keyfactor/stage" + index=0 + for file in $(find ./ejbca/staging -maxdepth 1 -mindepth 1); do + configMapName="$(configmapNameFromFilename "$file")" + volume_name="$(echo "$configMapName" | sed 's/\.[^.]*$//')" + + helm_install_args+=("--set" "ejbca.volumes[$index].name=$volume_name") + helm_install_args+=("--set" "ejbca.volumes[$index].configMapName=$configMapName") + helm_install_args+=("--set" "ejbca.volumes[$index].mountPath=$container_staging_dir/$configMapName") + index=$((index + 1)) + done + + helm_install_args+=("--set" "ejbca.volumes[$index].name=ejbca-init") + helm_install_args+=("--set" "ejbca.volumes[$index].configMapName=ejbca-init") + helm_install_args+=("--set" "ejbca.volumes[$index].mountPath=/tmp/") + + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[0].name=EJBCA_SUPERADMIN_COMMONNAME") + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[0].value=SuperAdmin") + + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[1].name=EJBCA_SUPERADMIN_SECRET_NAME") + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[1].value=$EJBCA_SUPERADMIN_SECRET_NAME") + + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[2].name=EJBCA_MANAGEMENTCA_SECRET_NAME") + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[2].value=$EJBCA_MANAGEMENTCA_SECRET_NAME") + + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[2].name=EJBCA_SUBCA_SECRET_NAME") + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[2].value=$EJBCA_SUBCA_SECRET_NAME") + + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[3].name=EJBCA_ROOT_CA_NAME") + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[3].value=$EJBCA_ROOT_CA_NAME") + + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[4].name=EJBCA_SUB_CA_NAME") + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[4].value=$EJBCA_SUB_CA_NAME") + + k8s_reverseproxy_service_fqdn="ejbca-rp-service.$EJBCA_NAMESPACE.svc.cluster.local" + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[5].name=EJBCA_CLUSTER_REVERSEPROXY_FQDN") + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[5].value=$k8s_reverseproxy_service_fqdn") + + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[6].name=EJBCA_RP_TLS_SECRET_NAME") + helm_install_args+=("--set" "ejbca.extraEnvironmentVars[6].value=ejbca-reverseproxy-tls") + + helm_install_args+=("--set" "ejbca.image.repository=$EJBCA_IMAGE") + helm_install_args+=("--set" "ejbca.image.tag=$EJBCA_TAG") + if [ ! -z "$IMAGE_PULL_SECRET_NAME" ]; then + helm_install_args+=("--set" "ejbca.image.pullSecrets[0].name=$IMAGE_PULL_SECRET_NAME") + fi + + if ! ../bin/helm "${helm_install_args[@]}" ; then + echo "Failed to install EJBCA" + ../bin/kubectl delete namespace "$EJBCA_NAMESPACE" + exit 1 + fi + + # Wait for the EJBCA Pod to be ready + echo "Waiting for EJBCA Pod to be ready" + ../bin/kubectl --namespace "$EJBCA_NAMESPACE" wait --for=condition=Available deployment -l app.kubernetes.io/name=ejbca --timeout=300s + ../bin/kubectl --namespace "$EJBCA_NAMESPACE" wait --for=condition=Ready pod -l app.kubernetes.io/name=ejbca --timeout=300s + + # Get the name of the EJBCA Pod + local ejbca_pod_name + ejbca_pod_name=$(../bin/kubectl --namespace "$EJBCA_NAMESPACE" get pods -l app.kubernetes.io/name=ejbca -o json | jq '.items[] | select(.metadata.labels."app.kubernetes.io/name" == "ejbca") | .metadata.name' | tr -d '"') + + if [ "$ejbca_pod_name" == "" ]; then + echo "Failed to get the name of the EJBCA Pod" + ../bin/kubectl delete ns "$EJBCA_NAMESPACE" + exit 1 + fi + + # Wait for the EJBCA Pod to be ready + waitForEJBCANode "$EJBCA_NAMESPACE" "$ejbca_pod_name" + + # Execute the EJBCA init script + args=( + --namespace "$EJBCA_NAMESPACE" exec "$ejbca_pod_name" -- + bash -c 'cp /tmp/ejbca-init.sh /opt/keyfactor/bin/ejbca-init.sh && chmod +x /opt/keyfactor/bin/ejbca-init.sh && /opt/keyfactor/bin/ejbca-init.sh' + ) + if ! ../bin/kubectl "${args[@]}" ; then + echo "Failed to execute the EJBCA init script" + ../bin/kubectl delete ns "$EJBCA_NAMESPACE" + exit 1 + fi + + # Uninstall the EJBCA helm chart - database is peristent + ../bin/helm --namespace "$EJBCA_NAMESPACE" uninstall ejbca-test + cleanupEJBCAConfigMaps +} + +# Deploy EJBCA with ingress enabled +deployEJBCA() { + # Package and deploy the EJBCA helm chart with ingress enabled + helm_install_args=( + "--namespace" + "$EJBCA_NAMESPACE" + "install" + "ejbca-test" + "./ejbca" + "--set" + "ejbca.ingress.enabled=false" + ) + helm_install_args+=("--set" "ejbca.reverseProxy.enabled=true") + + helm_install_args+=("--set" "ejbca.image.repository=$EJBCA_IMAGE") + helm_install_args+=("--set" "ejbca.image.tag=$EJBCA_TAG") + if [ ! -z "$IMAGE_PULL_SECRET_NAME" ]; then + helm_install_args+=("--set" "ejbca.image.pullSecrets[0].name=$IMAGE_PULL_SECRET_NAME") + fi + + if ! ../bin/helm "${helm_install_args[@]}" ; then + echo "Failed to install EJBCA" + exit 1 + fi + + sleep 20 + + # Wait for the EJBCA Pod to be ready + echo "Waiting for EJBCA Pod to be ready" + ../bin/kubectl --namespace "$EJBCA_NAMESPACE" wait --for=condition=ready pod -l app.kubernetes.io/instance=ejbca-test --timeout=300s + + # Get the name of the EJBCA Pod + local ejbca_pod_name + ejbca_pod_name=$(../bin/kubectl --namespace "$EJBCA_NAMESPACE" get pods -l app.kubernetes.io/name=ejbca -o json | jq '.items[] | select(.metadata.labels."app.kubernetes.io/name" == "ejbca") | .metadata.name' | tr -d '"') + + # Wait for the EJBCA node to be ready + waitForEJBCANode "$EJBCA_NAMESPACE" "$ejbca_pod_name" + + sleep 5 +} + +uninstallEJBCA() { + if ! isEjbcaAlreadyDeployed; then + echo "EJBCA is not deployed" + return 1 + fi + + ../bin/helm --namespace "$EJBCA_NAMESPACE" uninstall ejbca-test + + ../bin/kubectl delete namespace "$EJBCA_NAMESPACE" +} + +############################################### +# Helper Functions # +############################################### + +mariadbPvcExists() { + local namespace=$1 + + if [ "$(../bin/kubectl --namespace "$namespace" get pvc -l app.kubernetes.io/name=mariadb -o json | jq '.items[] | select(.metadata.labels."app.kubernetes.io/name" == "mariadb") | .metadata.name' | tr -d '"')" != "" ]; then + return 0 + else + return 1 + fi +} + +usage() { + echo "Usage: $0 [options...]" + echo "Options:" + echo " --ejbca-image Set the image to use for the EJBCA node. Defaults to keyfactor/ejbca-ce" + echo " --ejbca-tag Set the tag to use for the EJBCA node. Defaults to latest" + echo " --image-pull-secret Use a particular image pull secret in the ejbca namespace for the EJBCA node. Defaults to none" + echo " --ejbca-namespace Set the namespace to deploy the EJBCA node in. Defaults to ejbca" + echo " --superadmin-secret-name The name of the secret that will be created containing the SuperAdmin (client certificate)" + echo " --managementca-secret-name The name of the secret that will be created containing the ManagementCA certificate" + echo " --subca-secret-name The name of the secret that will be created containing the SubCA certificate and chain" + echo " --uninstall Uninstall EJBCA and SignServer" + echo " -h, --help Show this help message" + exit 1 +} + +# Verify that required tools are installed +verifySupported + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --ejbca-namespace) + EJBCA_NAMESPACE="$2" + shift # past argument + shift # past value + ;; + --ejbca-image) + EJBCA_IMAGE="$2" + shift # past argument + shift # past value + ;; + --superadmin-secret-name) + EJBCA_SUPERADMIN_SECRET_NAME="$2" + shift # past argument + shift # past value + ;; + --managementca-secret-name) + EJBCA_MANAGEMENTCA_SECRET_NAME="$2" + shift # past argument + shift # past value + ;; + --subca-secret-name) + EJBCA_SUBCA_SECRET_NAME="$2" + shift # past argument + shift # past value + ;; + --ejbca-tag) + EJBCA_TAG="$2" + shift # past argument + shift # past value + ;; + --image-pull-secret) + IMAGE_PULL_SECRET_NAME="$2" + shift # past argument + shift # past value + ;; + --uninstall) + uninstallEJBCA + exit 0 + ;; + -h|--help) + usage + exit 0 + ;; + *) # unknown option + echo "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +# Figure out if the cluster is already initialized for EJBCA +if ! isEjbcaAlreadyDeployed; then + if mariadbPvcExists "$EJBCA_NAMESPACE"; then + echo "The EJBCA database has already been configured - skipping database initialization" + + # Deploy EJBCA with ingress enabled + deployEJBCA + else + # Prepare the cluster for EJBCA + initClusterForEJBCA + + # Initialize the database by spinning up an instance of EJBCA infront of a MariaDB database, and then + # create the CA hierarchy and import boilerplate profiles. + initEJBCADatabase + + # Deploy EJBCA with ingress enabled + deployEJBCA + fi +fi diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/.helmignore b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/.helmignore new file mode 100644 index 0000000000..0e8a0eb36f --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/Chart.yaml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/Chart.yaml new file mode 100644 index 0000000000..5d4aff4359 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: ejbca +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/scripts/ejbca-init.sh b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/scripts/ejbca-init.sh new file mode 100755 index 0000000000..4b58e0acdc --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/scripts/ejbca-init.sh @@ -0,0 +1,355 @@ +#!/bin/bash + +########### +# General # +########### + +ejbcactl() { + local args=("${@:1}") + + echo "ejbca.sh ${args[*]}" + + if ! /opt/keyfactor/bin/ejbca.sh "${args[@]}" ; then + echo "ejbca.sh failed with args: ${args[*]}" + exit 1 + fi + + return 0 +} + + +############## +# Kubernetes # +############## + +createK8sTLSSecret() { + local secret_name=$1 + local cert_file=$2 + local key_file=$3 + + namespace=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace) + secret_url="https://$KUBERNETES_PORT_443_TCP_ADDR:$KUBERNETES_SERVICE_PORT_HTTPS/api/v1/namespaces/$namespace/secrets" + ca_cert_path="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + token=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) + + cert=$(cat $cert_file | base64 | tr -d '\n') + key=$(cat $key_file | base64 | tr -d '\n') + + read -r -d '' PAYLOAD < $tokenProperties +echo "crlSignKey signKey" >> $tokenProperties +echo "keyEncryptKey encryptKey" >> $tokenProperties +echo "testKey testKey" >> $tokenProperties +echo "defaultKey encryptKey" >> $tokenProperties + +root_ca_name="$EJBCA_ROOT_CA_NAME" +if [ -z "$root_ca_name" ]; then + echo "Using default root CA name Root-CA" + root_ca_name="Root-CA" +fi +sub_ca_name="$EJBCA_SUB_CA_NAME" +if [ -z "$sub_ca_name" ]; then + echo "Using default sub CA name Sub-CA" + sub_ca_name="Sub-CA" +fi + +createRootCA "ManagementCA" "$tokenProperties" +createRootCA "$root_ca_name" "$tokenProperties" +createSubCA "$sub_ca_name" "$tokenProperties" "$root_ca_name" + +############################ +# Import staged EEPs & CPs # +############################ + +container_staging_dir="$EJBCA_EEP_CP_STAGE_DIR" +if [ ! -s "$container_staging_dir" ]; then + echo "Using default staging directory /opt/keyfactor/stage" + container_staging_dir="/opt/keyfactor/stage" +fi + +# Import end entity profiles from staging area +for file in "$container_staging_dir"/*; do + echo "Importing profile from $file" + ejbcactl ca importprofiles -d "$file" +done + +########################################## +# Create SuperAdmin certificate and role # +########################################## + +common_name="$EJBCA_SUPERADMIN_COMMONNAME" +if [ -z "$common_name" ]; then + echo "Using default common name SuperAdmin" + common_name="SuperAdmin" +fi + +superadmin_secret_name="$EJBCA_SUPERADMIN_SECRET_NAME" +if [ -z "$superadmin_secret_name" ]; then + echo "Using default secret name superadmin-tls" + superadmin_secret_name="superadmin-tls" +fi + +managementca_secret_name="$EJBCA_MANAGEMENTCA_SECRET_NAME" +if [ -z "$managementca_secret_name" ]; then + echo "Using default secret name managementca" + managementca_secret_name="managementca" +fi + +# Create SuperAdmin +ejbcactl ra addendentity \ + --username "SuperAdmin" \ + --dn "CN=$common_name" \ + --caname "ManagementCA" \ + --certprofile "Authentication-2048-3y" \ + --eeprofile "adminInternal" \ + --type 1 \ + --token "PEM" \ + --password "foo123" + +ejbcactl ra setclearpwd SuperAdmin foo123 +ejbcactl batch + +superadmin_cert="/opt/keyfactor/p12/pem/$common_name.pem" +superadmin_key="/opt/keyfactor/p12/pem/$common_name-Key.pem" +createK8sTLSSecret "$superadmin_secret_name" "$superadmin_cert" "$superadmin_key" +managementca_cert="/opt/keyfactor/p12/pem/$common_name-CA.pem" +createK8sOpaqueSecret "$managementca_secret_name" "ca.crt" "$(cat $managementca_cert | base64 | tr -d '\n')" + +# Add a role to allow the SuperAdmin to access the node +ejbcactl roles addrolemember \ + --role 'Super Administrator Role' \ + --caname 'ManagementCA' \ + --with 'WITH_COMMONNAME' \ + --value "$common_name" + +# Enable the /ejbca/ejbca-rest-api endpoint +ejbcactl config protocols enable --name "REST Certificate Management" + +######################################### +# Create the in-cluster TLS certificate # +######################################### + +subca_secret_name="$EJBCA_SUBCA_SECRET_NAME" +if [ -z "$managementca_secret_name" ]; then + echo "Using default secret name subca" + managementca_secret_name="subca" +fi + +reverseproxy_fqdn="$EJBCA_CLUSTER_REVERSEPROXY_FQDN" +if [ -z "$reverseproxy_fqdn" ]; then + echo "Skipping in-cluster reverse proxy TLS config - EJBCA_CLUSTER_REVERSEPROXY_FQDN not set" + return 0 +fi + +reverseproxy_secret_name="$EJBCA_RP_TLS_SECRET_NAME" +if [ -z "$reverseproxy_secret_name" ]; then + echo "Using default reverseproxy secret name ejbca-reverseproxy-tls" + ingress_secret_name="ejbca-reverseproxy-tls" +fi + +echo "Creating server certificate for $reverseproxy_fqdn" +ejbcactl ra addendentity \ + --username "$reverseproxy_fqdn" \ + --altname dNSName="$reverseproxy_fqdn" \ + --dn "CN=$reverseproxy_fqdn" \ + --caname "Sub-CA" \ + --certprofile "tlsServerAuth" \ + --eeprofile "tlsServerAnyCA" \ + --type 1 \ + --token "PEM" \ + --password "foo123" + +ejbcactl ra setclearpwd "$reverseproxy_fqdn" foo123 +ejbcactl batch + +ls -l "/opt/keyfactor/p12/pem" + +server_cert="/opt/keyfactor/p12/pem/$reverseproxy_fqdn.pem" +server_key="/opt/keyfactor/p12/pem/$reverseproxy_fqdn-Key.pem" +createK8sTLSSecret "$reverseproxy_secret_name" "$server_cert" "$server_key" +subca_cert="/opt/keyfactor/p12/pem/$reverseproxy_fqdn-CA.pem" +createK8sOpaqueSecret "$subca_secret_name" "ca.crt" "$(cat $subca_cert | base64 | tr -d '\n')" diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/certprofile_Authentication-2048-3y-1510586178.xml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/certprofile_Authentication-2048-3y-1510586178.xml new file mode 100644 index 0000000000..c0cb8e317d --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/certprofile_Authentication-2048-3y-1510586178.xml @@ -0,0 +1,552 @@ + + + + + version + 51.0 + + + type + 1 + + + certversion + X509v3 + + + encodedvalidity + 3y + + + usecertificatevalidityoffset + false + + + certificatevalidityoffset + -10m + + + useexpirationrestrictionforweekdays + false + + + expirationrestrictionforweekdaysbefore + true + + + expirationrestrictionweekdays + + + true + + + true + + + false + + + false + + + false + + + true + + + true + + + + + allowvalidityoverride + false + + + description + + + + allowextensionoverride + false + + + allowdnoverride + false + + + allowdnoverridebyeei + false + + + allowbackdatedrevokation + false + + + usecertificatestorage + true + + + storecertificatedata + true + + + storesubjectaltname + true + + + usebasicconstrants + false + + + basicconstraintscritical + true + + + usesubjectkeyidentifier + true + + + subjectkeyidentifiercritical + false + + + useauthoritykeyidentifier + true + + + authoritykeyidentifiercritical + false + + + usesubjectalternativename + true + + + subjectalternativenamecritical + false + + + useissueralternativename + false + + + issueralternativenamecritical + false + + + usecrldistributionpoint + true + + + usedefaultcrldistributionpoint + true + + + crldistributionpointcritical + false + + + crldistributionpointuri + + + + usefreshestcrl + false + + + usecadefinedfreshestcrl + false + + + freshestcrluri + + + + crlissuer + + + + usecertificatepolicies + false + + + certificatepoliciescritical + false + + + certificatepolicies + + + + availablekeyalgorithms + + + RSA + + + + + availableeccurves + + + ANY_EC_CURVE + + + + + availablebitlengths + + + 2048 + + + + + minimumavailablebitlength + 2048 + + + maximumavailablebitlength + 2048 + + + signaturealgorithm + SHA256WithRSA + + + usekeyusage + true + + + keyusage + + + true + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + + + allowkeyusageoverride + false + + + keyusagecritical + true + + + useextendedkeyusage + true + + + extendedkeyusage + + + 1.3.6.1.5.5.7.3.2 + + + 1.3.6.1.5.2.3.4 + + + 1.3.6.1.4.1.311.20.2.2 + + + 1.3.6.1.5.5.7.3.21 + + + + + extendedkeyusagecritical + false + + + usedocumenttypelist + false + + + documenttypelistcritical + false + + + documenttypelist + + + + availablecas + + + -1 + + + + + usedpublishers + + + + useocspnocheck + false + + + useldapdnorder + false + + + usecustomdnorder + false + + + usemicrosofttemplate + false + + + microsofttemplate + + + + usecardnumber + false + + + usecnpostfix + false + + + cnpostfix + + + + usesubjectdnsubset + false + + + subjectdnsubset + + + + usesubjectaltnamesubset + false + + + subjectaltnamesubset + + + + usepathlengthconstraint + false + + + pathlengthconstraint + 0 + + + useqcstatement + false + + + usepkixqcsyntaxv2 + false + + + useqcstatementcritical + false + + + useqcstatementraname + + + + useqcsematicsid + + + + useqcetsiqccompliance + false + + + useqcetsisignaturedevice + false + + + useqcetsivaluelimit + false + + + qcetsivaluelimit + 0 + + + qcetsivaluelimitexp + 0 + + + qcetsivaluelimitcurrency + + + + useqcetsiretentionperiod + false + + + qcetsiretentionperiod + 0 + + + useqccustomstring + false + + + qccustomstringoid + + + + qccustomstringtext + + + + qcetsipds + + + + qcetsitype + + + + usecertificatetransparencyincerts + false + + + usecertificatetransparencyinocsp + false + + + usecertificatetransparencyinpublisher + false + + + usesubjectdirattributes + false + + + usenameconstraints + false + + + useauthorityinformationaccess + true + + + caissuers + + + + usedefaultcaissuer + true + + + usedefaultocspservicelocator + true + + + ocspservicelocatoruri + + + + cvcaccessrights + 0 + + + usedcertificateextensions + + + + approvals + + + + useprivkeyusageperiodnotbefore + false + + + useprivkeyusageperiod + false + + + useprivkeyusageperiodnotafter + false + + + privkeyusageperiodstartoffset + 0 + + + privkeyusageperiodlength + 63072000 + + + usesingleactivecertificateconstraint + false + + + overridableextensionoids + + + + nonoverridableextensionoids + + + + numofreqapprovals + 1 + + + approvalsettings + + + + approvalProfile + -1 + + + useqccountries + false + + + qccountriestring + + + + usemsobjectsidextension + true + + + usetruncatedsubjectkeyidentifier + false + + + diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/certprofile_tlsServerAuth-1841776707.xml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/certprofile_tlsServerAuth-1841776707.xml new file mode 100644 index 0000000000..20e3ea5118 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/certprofile_tlsServerAuth-1841776707.xml @@ -0,0 +1,588 @@ + + + + + version + 51.0 + + + type + 1 + + + certversion + X509v3 + + + encodedvalidity + 10y + + + usecertificatevalidityoffset + false + + + certificatevalidityoffset + -10m + + + useexpirationrestrictionforweekdays + false + + + expirationrestrictionforweekdaysbefore + true + + + expirationrestrictionweekdays + + + true + + + true + + + false + + + false + + + false + + + true + + + true + + + + + allowvalidityoverride + false + + + description + + + + allowextensionoverride + false + + + allowdnoverride + false + + + allowdnoverridebyeei + false + + + allowbackdatedrevokation + false + + + usecertificatestorage + true + + + storecertificatedata + true + + + storesubjectaltname + false + + + usebasicconstrants + false + + + basicconstraintscritical + true + + + usesubjectkeyidentifier + true + + + subjectkeyidentifiercritical + false + + + useauthoritykeyidentifier + true + + + authoritykeyidentifiercritical + false + + + usesubjectalternativename + true + + + subjectalternativenamecritical + false + + + useissueralternativename + false + + + issueralternativenamecritical + false + + + usecrldistributionpoint + true + + + usedefaultcrldistributionpoint + true + + + crldistributionpointcritical + false + + + crldistributionpointuri + + + + usefreshestcrl + false + + + usecadefinedfreshestcrl + false + + + freshestcrluri + + + + crlissuer + + + + usecertificatepolicies + false + + + certificatepoliciescritical + false + + + certificatepolicies + + + + availablekeyalgorithms + + + RSA + + + + + availableeccurves + + + ANY_EC_CURVE + + + + + availablebitlengths + + + 2048 + + + 3072 + + + + + minimumavailablebitlength + 2048 + + + maximumavailablebitlength + 3072 + + + signaturealgorithm + SHA256WithRSA + + + usekeyusage + true + + + keyusage + + + true + + + false + + + true + + + false + + + false + + + false + + + false + + + false + + + false + + + + + allowkeyusageoverride + false + + + keyusagecritical + true + + + useextendedkeyusage + true + + + extendedkeyusage + + + 1.3.6.1.5.5.7.3.1 + + + + + extendedkeyusagecritical + false + + + usedocumenttypelist + false + + + documenttypelistcritical + false + + + documenttypelist + + + + availablecas + + + -1 + + + + + usedpublishers + + + + useocspnocheck + false + + + useldapdnorder + false + + + usecustomdnorder + false + + + usemicrosofttemplate + false + + + microsofttemplate + + + + usecardnumber + false + + + usecnpostfix + false + + + cnpostfix + + + + usesubjectdnsubset + false + + + subjectdnsubset + + + + usesubjectaltnamesubset + false + + + subjectaltnamesubset + + + + usepathlengthconstraint + false + + + pathlengthconstraint + 0 + + + useqcstatement + false + + + usepkixqcsyntaxv2 + false + + + useqcstatementcritical + false + + + useqcstatementraname + + + + useqcsematicsid + + + + useqcetsiqccompliance + false + + + useqcetsisignaturedevice + false + + + useqcetsivaluelimit + false + + + qcetsivaluelimit + 0 + + + qcetsivaluelimitexp + 0 + + + qcetsivaluelimitcurrency + + + + useqcetsiretentionperiod + false + + + qcetsiretentionperiod + 0 + + + useqccustomstring + false + + + qccustomstringoid + + + + qccustomstringtext + + + + qcetsipds + + + + qcetsitype + + + + usecertificatetransparencyincerts + false + + + usecertificatetransparencyinocsp + false + + + usecertificatetransparencyinpublisher + false + + + usesubjectdirattributes + false + + + usenameconstraints + false + + + useauthorityinformationaccess + true + + + caissuers + + + + usedefaultcaissuer + true + + + usedefaultocspservicelocator + true + + + ocspservicelocatoruri + + + + cvcaccessrights + 0 + + + usedcertificateextensions + + + + approvals + + + + org.cesecore.certificates.ca.ApprovalRequestType + REVOCATION + + -1 + + + + org.cesecore.certificates.ca.ApprovalRequestType + KEYRECOVER + + -1 + + + + org.cesecore.certificates.ca.ApprovalRequestType + ADDEDITENDENTITY + + -1 + + + + + useprivkeyusageperiodnotbefore + false + + + useprivkeyusageperiod + false + + + useprivkeyusageperiodnotafter + false + + + privkeyusageperiodstartoffset + 0 + + + privkeyusageperiodlength + 63072000 + + + usesingleactivecertificateconstraint + false + + + overridableextensionoids + + + + nonoverridableextensionoids + + + + allowcertsnoverride + false + + + usecabforganizationidentifier + false + + + usecustomdnorderldap + false + + + numofreqapprovals + 1 + + + approvalsettings + + + + approvalProfile + -1 + + + useqccountries + false + + + qccountriestring + + + + eabnamespaces + + + + usemsobjectsidextension + true + + + usetruncatedsubjectkeyidentifier + false + + + keyusageforbidencyrptionusageforecc + false + + + diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/entityprofile_adminInternal-151490904.xml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/entityprofile_adminInternal-151490904.xml new file mode 100644 index 0000000000..d47d8af1c5 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/entityprofile_adminInternal-151490904.xml @@ -0,0 +1,1529 @@ + + + + + SUBJECTDNFIELDORDER + + + 50000 + + + + + SUBJECTALTNAMEFIELDORDER + + + + SUBJECTDIRATTRFIELDORDER + + + + SSH_FIELD_ORDER + + + + version + 18.0 + + + NUMBERARRAY + + + 1 + + + 1 + + + 0 + + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 0 + + + 1 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 1 + + + 1 + + + 1 + + + 0 + + + 1 + + + 0 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + + 2000000 + true + + + 1000000 + true + + + 3000000 + true + + + 5000000 + false + + + 1 + + + + 2000001 + true + + + 1000001 + true + + + 3000001 + true + + + 5000001 + false + + + 95 + + + + 2000095 + false + + + 1000095 + true + + + 3000095 + true + + + 5000095 + false + + + 96 + 8 + + + 2000096 + false + + + 1000096 + true + + + 3000096 + true + + + 5000096 + false + + + 26 + + + + 2000026 + false + + + 1000026 + true + + + 3000026 + true + + + 5000026 + false + + + 29 + 1510586178 + + + 2000029 + true + + + 1000029 + true + + + 3000029 + true + + + 5000029 + false + + + 30 + 1510586178 + + + 2000030 + true + + + 1000030 + true + + + 3000030 + true + + + 5000030 + false + + + 31 + 1 + + + 2000031 + true + + + 1000031 + true + + + 3000031 + true + + + 5000031 + false + + + 32 + 1;2;3;4 + + + 2000032 + true + + + 1000032 + true + + + 3000032 + true + + + 5000032 + false + + + 33 + + + + 2000033 + false + + + 1000033 + true + + + 3000033 + true + + + 5000033 + false + + + 34 + + + + 2000034 + true + + + 1000034 + false + + + 3000034 + true + + + 5000034 + false + + + 38 + 1999392212 + + + 2000038 + true + + + 1000038 + true + + + 3000038 + true + + + 5000038 + false + + + 37 + 1999392212 + + + 2000037 + true + + + 1000037 + true + + + 3000037 + true + + + 5000037 + false + + + 98 + + + + 2000098 + false + + + 1000098 + false + + + 3000098 + true + + + 5000098 + false + + + 99 + + + + 2000099 + false + + + 1000099 + false + + + 3000099 + true + + + 5000099 + false + + + 97 + + + + 2000097 + false + + + 1000097 + false + + + 3000097 + true + + + 5000097 + false + + + 91 + + + + 2000091 + false + + + 1000091 + false + + + 3000091 + true + + + 5000091 + false + + + 94 + -1 + + + 2000094 + false + + + 1000094 + false + + + 3000094 + false + + + 5000094 + false + + + 93 + -1 + + + 2000093 + false + + + 1000093 + false + + + 3000093 + false + + + 5000093 + false + + + 89 + + + + 2000089 + false + + + 1000089 + false + + + 3000089 + true + + + 5000089 + false + + + 88 + + + + 2000088 + false + + + 1000088 + false + + + 3000088 + true + + + 5000088 + false + + + 87 + + + + 2000087 + false + + + 1000087 + false + + + 3000087 + false + + + 5000087 + false + + + 5 + + + + 2000005 + true + + + 1000005 + true + + + 3000005 + true + + + 5000005 + false + + + 60 + + + + 1000090 + true + + + 90 + 0 + + + 1000002 + false + + + 2 + false + + + 2000002 + false + + + REVERSEFFIELDCHECKS + false + + + ALLOW_MERGEDN_WEBSERVICES + false + + + ALLOW_MULTI_VALUE_RDNS + false + + + 201 + + + + 2000201 + false + + + 3000201 + false + + + 202 + + + + 2000202 + false + + + 3000202 + false + + + 1000092 + false + + + USEEXTENSIONDATA + false + + + PSD2QCSTATEMENT + false + + + 1000035 + false + + + PRINTINGUSE + false + + + USERNOTIFICATIONS + + + + 1000028 + false + + + 2000028 + false + + + 28 + false + + + 2000035 + false + + + 35 + false + + + PRINTINGREQUIRED + false + + + PRINTINGDEFAULT + false + + + ALLOW_MERGEDN + false + + + PROFILETYPE + 1 + + + 110 + + + + 1000086 + false + + + REDACTPII + false + + + diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/entityprofile_spireIntermediateCA-1440949471.xml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/entityprofile_spireIntermediateCA-1440949471.xml new file mode 100644 index 0000000000..35fe10fafa --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/entityprofile_spireIntermediateCA-1440949471.xml @@ -0,0 +1,980 @@ + + + + + version + 18.0 + + + NUMBERARRAY + + + 1 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 0 + + + 1 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 1 + + + 0 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + SUBJECTDNFIELDORDER + + + 120000 + + + 160000 + + + 60000 + + + + + SUBJECTALTNAMEFIELDORDER + + + 210000 + + + + + SUBJECTDIRATTRFIELDORDER + + + + SSH_FIELD_ORDER + + + + PROFILETYPE + 1 + + + 0 + + + + 2000000 + true + + + 1000000 + true + + + 3000000 + true + + + 5000000 + false + + + 1 + + + + 2000001 + true + + + 1000001 + true + + + 3000001 + true + + + 5000001 + false + + + 95 + + + + 2000095 + false + + + 1000095 + true + + + 3000095 + true + + + 5000095 + false + + + 96 + 8 + + + 2000096 + false + + + 1000096 + true + + + 3000096 + true + + + 5000096 + false + + + 26 + + + + 2000026 + false + + + 1000026 + true + + + 3000026 + true + + + 5000026 + false + + + 29 + 2 + + + 2000029 + true + + + 1000029 + true + + + 3000029 + true + + + 5000029 + false + + + 30 + 2 + + + 2000030 + true + + + 1000030 + true + + + 3000030 + true + + + 5000030 + false + + + 31 + 1 + + + 2000031 + true + + + 1000031 + true + + + 3000031 + true + + + 5000031 + false + + + 32 + 1;2;5;3;4 + + + 2000032 + true + + + 1000032 + true + + + 3000032 + true + + + 5000032 + false + + + 33 + + + + 2000033 + false + + + 1000033 + true + + + 3000033 + true + + + 5000033 + false + + + 34 + + + + 2000034 + true + + + 1000034 + false + + + 3000034 + true + + + 5000034 + false + + + 38 + 1 + + + 2000038 + true + + + 1000038 + true + + + 3000038 + true + + + 5000038 + false + + + 37 + 419280416 + + + 2000037 + true + + + 1000037 + true + + + 3000037 + true + + + 5000037 + false + + + 98 + + + + 2000098 + false + + + 1000098 + false + + + 3000098 + true + + + 5000098 + false + + + 99 + + + + 2000099 + false + + + 1000099 + false + + + 3000099 + true + + + 5000099 + false + + + 97 + + + + 2000097 + false + + + 1000097 + false + + + 3000097 + true + + + 5000097 + false + + + 91 + + + + 2000091 + false + + + 1000091 + false + + + 3000091 + true + + + 5000091 + false + + + 94 + -1 + + + 2000094 + false + + + 1000094 + false + + + 3000094 + true + + + 5000094 + false + + + 93 + -1 + + + 2000093 + false + + + 1000093 + false + + + 3000093 + true + + + 5000093 + false + + + 89 + + + + 2000089 + false + + + 1000089 + false + + + 3000089 + true + + + 5000089 + false + + + 88 + + + + 2000088 + false + + + 1000088 + false + + + 3000088 + true + + + 5000088 + false + + + 87 + + + + 2000087 + false + + + 1000087 + false + + + 3000087 + true + + + 5000087 + false + + + 86 + 7 + + + 2000086 + false + + + 1000086 + false + + + 3000086 + false + + + 5000086 + false + + + 3000201 + true + + + 3000202 + true + + + 3000203 + true + + + 12 + + + + 2000012 + false + + + 1000012 + true + + + 3000012 + true + + + 5000012 + false + + + 16 + + + + 2000016 + false + + + 1000016 + true + + + 3000016 + true + + + 5000016 + false + + + 21 + + + + 2000021 + false + + + 1000021 + true + + + 3000021 + true + + + 5000021 + false + + + 1000090 + true + + + 90 + 0 + + + 1000002 + false + + + 2 + false + + + 2000002 + false + + + 110 + + + + REVERSEFFIELDCHECKS + false + + + ALLOW_MERGEDN + false + + + ALLOW_MULTI_VALUE_RDNS + false + + + 1000092 + false + + + USEEXTENSIONDATA + false + + + PSD2QCSTATEMENT + false + + + 1000028 + false + + + REDACTPII + false + + + 1000035 + false + + + USERNOTIFICATIONS + + + + 2000028 + false + + + 28 + false + + + 2000035 + false + + + 35 + false + + + 6 + + + + 2000006 + false + + + 1000006 + true + + + 3000006 + true + + + 5000006 + false + + + diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/entityprofile_tlsServerAnyCA-327363278.xml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/entityprofile_tlsServerAnyCA-327363278.xml new file mode 100644 index 0000000000..31610d6bf2 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/staging/entityprofile_tlsServerAnyCA-327363278.xml @@ -0,0 +1,934 @@ + + + + + version + 18.0 + + + NUMBERARRAY + + + 1 + + + 1 + + + 0 + + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 0 + + + 1 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 1 + + + 0 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + SUBJECTDNFIELDORDER + + + 50000 + + + + + SUBJECTALTNAMEFIELDORDER + + + 180000 + + + + + SUBJECTDIRATTRFIELDORDER + + + + SSH_FIELD_ORDER + + + + PROFILETYPE + 1 + + + 0 + + + + 2000000 + true + + + 1000000 + true + + + 3000000 + true + + + 5000000 + false + + + 1 + + + + 2000001 + true + + + 1000001 + true + + + 3000001 + true + + + 5000001 + false + + + 95 + + + + 2000095 + false + + + 1000095 + true + + + 3000095 + true + + + 5000095 + false + + + 96 + 8 + + + 2000096 + false + + + 1000096 + true + + + 3000096 + true + + + 5000096 + false + + + 5 + + + + 2000005 + true + + + 1000005 + true + + + 3000005 + true + + + 5000005 + false + + + 26 + + + + 2000026 + false + + + 1000026 + true + + + 3000026 + true + + + 5000026 + false + + + 29 + 9 + + + 2000029 + true + + + 1000029 + true + + + 3000029 + true + + + 5000029 + false + + + 30 + 9;781718050;1841776707 + + + 2000030 + true + + + 1000030 + true + + + 3000030 + true + + + 5000030 + false + + + 31 + 1 + + + 2000031 + true + + + 1000031 + true + + + 3000031 + true + + + 5000031 + false + + + 32 + 1;2;5;3;4 + + + 2000032 + true + + + 1000032 + true + + + 3000032 + true + + + 5000032 + false + + + 33 + + + + 2000033 + false + + + 1000033 + true + + + 3000033 + true + + + 5000033 + false + + + 34 + + + + 2000034 + true + + + 1000034 + false + + + 3000034 + true + + + 5000034 + false + + + 38 + 1999392212;-1090145480;-913475458 + + + 2000038 + true + + + 1000038 + true + + + 3000038 + true + + + 5000038 + false + + + 37 + 1999392212 + + + 2000037 + true + + + 1000037 + true + + + 3000037 + true + + + 5000037 + false + + + 98 + + + + 2000098 + false + + + 1000098 + false + + + 3000098 + true + + + 5000098 + false + + + 99 + + + + 2000099 + false + + + 1000099 + false + + + 3000099 + true + + + 5000099 + false + + + 97 + + + + 2000097 + false + + + 1000097 + false + + + 3000097 + true + + + 5000097 + false + + + 91 + + + + 2000091 + false + + + 1000091 + false + + + 3000091 + true + + + 5000091 + false + + + 94 + -1 + + + 2000094 + false + + + 1000094 + false + + + 3000094 + true + + + 5000094 + false + + + 93 + -1 + + + 2000093 + false + + + 1000093 + false + + + 3000093 + true + + + 5000093 + false + + + 89 + + + + 2000089 + false + + + 1000089 + false + + + 3000089 + true + + + 5000089 + false + + + 88 + + + + 2000088 + false + + + 1000088 + false + + + 3000088 + true + + + 5000088 + false + + + 87 + + + + 2000087 + false + + + 1000087 + false + + + 3000087 + true + + + 5000087 + false + + + 86 + 7 + + + 2000086 + false + + + 1000086 + false + + + 3000086 + false + + + 5000086 + false + + + 3000201 + true + + + 3000202 + true + + + 3000203 + true + + + 18 + + + + 2000018 + false + + + 1000018 + true + + + 3000018 + true + + + 5000018 + false + + + 1000090 + true + + + 90 + 0 + + + 1000002 + false + + + 2 + false + + + 2000002 + false + + + 110 + + + + REVERSEFFIELDCHECKS + false + + + ALLOW_MERGEDN + false + + + ALLOW_MULTI_VALUE_RDNS + false + + + 1000092 + false + + + USEEXTENSIONDATA + false + + + PSD2QCSTATEMENT + false + + + REDACTPII + false + + + 1000035 + false + + + USERNOTIFICATIONS + + + + 1000028 + false + + + 2000028 + false + + + 28 + false + + + 2000035 + false + + + 35 + false + + + diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/_helpers.tpl b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/_helpers.tpl new file mode 100644 index 0000000000..f1df343ef0 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/_helpers.tpl @@ -0,0 +1,76 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "ejbca.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "ejbca.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "ejbca.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "ejbca.labels" -}} +helm.sh/chart: {{ include "ejbca.chart" . }} +{{ include "ejbca.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "ejbca.databaseLabels" -}} +helm.sh/chart: {{ include "ejbca.chart" . }} +{{ include "ejbca.databaseSelectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "ejbca.selectorLabels" -}} +app.kubernetes.io/name: {{ include "ejbca.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "ejbca.databaseSelectorLabels" -}} +app.kubernetes.io/name: mariadb +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "ejbca.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "ejbca.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/database-service.yaml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/database-service.yaml new file mode 100644 index 0000000000..1a4ab92a4c --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/database-service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.database.service.name }} + labels: + {{- include "ejbca.databaseLabels" . | nindent 4 }} +spec: + type: {{ .Values.database.service.type }} + ports: + - name: tcp-db-port + port: {{ .Values.database.service.port }} + targetPort: {{ .Values.database.service.port }} + selector: + {{- include "ejbca.databaseSelectorLabels" . | nindent 4 }} \ No newline at end of file diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/database-statefulset.yaml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/database-statefulset.yaml new file mode 100644 index 0000000000..94dc75b4f9 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/database-statefulset.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: mariadb-statefulset + labels: + {{- include "ejbca.databaseLabels" . | nindent 4 }} +spec: + serviceName: {{ .Values.database.service.name}} + replicas: 1 + selector: + matchLabels: + {{- include "ejbca.databaseSelectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "ejbca.databaseSelectorLabels" . | nindent 8 }} + spec: + {{- with .Values.database.image.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: mariadb + image: {{ .Values.database.image.repository }}:{{ .Values.database.image.tag }} + ports: + - containerPort: {{ .Values.database.service.port }} + name: mariadb + env: + - name: MARIADB_ROOT_PASSWORD + value: {{ .Values.database.rootPassword }} + - name: MARIADB_DATABASE + value: {{ .Values.database.name }} + - name: MARIADB_USER + value: {{ .Values.database.username }} + - name: MARIADB_PASSWORD + value: {{ .Values.database.password }} + volumeMounts: + - name: datadir + mountPath: /var/lib/mysql + volumeClaimTemplates: + - metadata: + name: datadir + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 10G \ No newline at end of file diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/ejbca-deployment.yaml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/ejbca-deployment.yaml new file mode 100644 index 0000000000..45ca5c977f --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/ejbca-deployment.yaml @@ -0,0 +1,151 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "ejbca.fullname" . }} + labels: + {{- include "ejbca.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.ejbca.replicaCount }} + selector: + matchLabels: + {{- include "ejbca.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.ejbca.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "ejbca.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.ejbca.image.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "ejbca.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.ejbca.image.repository }}:{{ .Values.ejbca.image.tag }}" + imagePullPolicy: {{ .Values.ejbca.image.pullPolicy }} + ports: + {{- range .Values.ejbca.containerPorts }} + - name: {{ .name }} + containerPort: {{ .containerPort }} + protocol: {{ .protocol }} + {{- end }} + startupProbe: + httpGet: + port: 8081 + path: /ejbca/publicweb/healthcheck/ejbcahealth + failureThreshold: 1000 # 50 * 2 seconds + 45-second delay gives 145 seconds for EJBCA to start + periodSeconds: 2 + initialDelaySeconds: 45 + livenessProbe: + httpGet: + port: 8081 + path: /ejbca/publicweb/healthcheck/ejbcahealth + env: + - name: DATABASE_JDBC_URL + value: "jdbc:mariadb://{{ .Values.database.service.name }}:{{ .Values.database.service.port }}/ejbca?characterEncoding=utf8" + - name: DATABASE_USER + value: {{ .Values.database.username }} + - name: DATABASE_PASSWORD + value: {{ .Values.database.password }} + - name: PROXY_HTTP_BIND + value: "{{ .Values.ejbca.proxyHttpBind }}" + - name: LOG_AUDIT_TO_DB + value: "{{ .Values.ejbca.logAuditToDatabase }}" + {{ if .Values.ejbca.ingress.enabled }} + - name: HTTPSERVER_HOSTNAME + value: "{{ index .Values.ejbca.ingress.hosts 0 "host" }}" + {{ end }} + - name: TLS_SETUP_ENABLED + value: "simple" + {{ if .Values.ejbca.extraEnvironmentVars }} + {{- range .Values.ejbca.extraEnvironmentVars }} + - name: {{ .name }} + value: {{ .value }} + {{- end }} + {{- end }} + volumeMounts: + {{- range .Values.ejbca.volumes }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.ejbca.reverseProxy.enabled }} + - name: httpd + image: "{{ .Values.ejbca.reverseProxy.image.repository }}:{{ .Values.ejbca.reverseProxy.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.ejbca.reverseProxy.image.pullPolicy }} + {{- if .Values.ejbca.reverseProxy.service.ports }} + ports: + {{- range .Values.ejbca.reverseProxy.service.ports }} + - name: {{ .name }} + containerPort: {{ .port }} + protocol: {{ .protocol }} + {{- end }} + {{- end }} + readinessProbe: + tcpSocket: + port: 8080 + volumeMounts: + - name: httpd-configmap + mountPath: /usr/local/apache2/conf/ + {{- if .Values.ejbca.reverseProxy.service.ports }} + {{- range .Values.ejbca.reverseProxy.service.ports }} + {{- if .authCASecretName }} + - name: {{ .authCASecretName }} + mountPath: {{ .baseCaCertDir }} + {{- end }} + {{- if .tlsSecretName }} + - name: {{ .tlsSecretName }} + mountPath: {{ .baseCertDir }} + {{- end }} + {{- end }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- end }} + volumes: + {{- range .Values.ejbca.volumes }} + - name: {{ .name }} + configMap: + name: {{ .configMapName }} + {{- end }} + {{- if .Values.ejbca.reverseProxy.enabled }} + - name: httpd-configmap + configMap: + name: httpd-configmap + {{- if .Values.ejbca.reverseProxy.service.ports }} + {{- range .Values.ejbca.reverseProxy.service.ports }} + {{- if .authCASecretName }} + - name: {{ .authCASecretName }} + secret: + secretName: {{ .authCASecretName }} + {{- end }} + {{- if .tlsSecretName }} + - name: {{ .tlsSecretName }} + secret: + secretName: {{ .tlsSecretName }} + {{- end }} + {{- end }} + {{- end }} + {{- end}} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/ejbca-reverseproxyconfig.yaml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/ejbca-reverseproxyconfig.yaml new file mode 100644 index 0000000000..1d947ae4c7 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/ejbca-reverseproxyconfig.yaml @@ -0,0 +1,113 @@ +{{- if .Values.ejbca.reverseProxy.enabled -}} +apiVersion: v1 +data: + httpd.conf: |+ + # Load required modules + LoadModule mpm_event_module modules/mod_mpm_event.so + LoadModule headers_module modules/mod_headers.so + LoadModule authz_core_module modules/mod_authz_core.so + LoadModule access_compat_module modules/mod_access_compat.so + LoadModule log_config_module modules/mod_log_config.so + LoadModule proxy_module modules/mod_proxy.so + LoadModule proxy_http_module modules/mod_proxy_http.so + LoadModule unixd_module modules/mod_unixd.so + LoadModule filter_module modules/mod_filter.so + LoadModule substitute_module modules/mod_substitute.so + LoadModule rewrite_module modules/mod_rewrite.so + LoadModule socache_shmcb_module modules/mod_socache_shmcb.so + LoadModule ssl_module modules/mod_ssl.so + + # Set default connection behavior + MaxKeepAliveRequests 1000 + KeepAlive On + KeepAliveTimeout 180 + + # Set basic security for Unix platform + + User daemon + Group daemon + + + # Set log configuration + ErrorLog /proc/self/fd/2 + LogLevel info + + LogFormat "%h %A:%p %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined + LogFormat "%h %A:%p %l %u %t \"%r\" %>s %b" common + CustomLog /proc/self/fd/1 common + + + ServerRoot "/usr/local/apache2" + Listen 8080 + {{- range .Values.ejbca.reverseProxy.service.ports }} + Listen {{ .port }} + {{- end }} + + + AllowOverride none + Require all denied + + + {{- range .Values.ejbca.reverseProxy.service.ports }} + + # Disallow any HTTP method that is not HEAD, GET or POST + RewriteEngine On + RewriteCond %{REQUEST_METHOD} !^(HEAD|GET|POST)$ [NC] + RewriteRule .* - [F,L] + + # Allow encoded slashes for OCSP GET + AllowEncodedSlashes On + + {{- if .tlsSecretName }} + SSLEngine On + SSLProtocol all -SSLv2 -SSLv3 -TLSv1 +TLSv1.2 -TLSv1.3 + SSLCertificateFile "{{ .baseCertDir }}/tls.crt" + SSLCertificateKeyFile "{{ .baseCertDir }}/tls.key" + RequestHeader set X-Forwarded-Proto "https" + {{- end }} + + {{ if .authCASecretName }} + SSLCACertificateFile "{{ .baseCaCertDir }}/ca.crt" + + + SSLVerifyClient optional + SSLOptions +ExportCertData +StdEnvVars + RequestHeader set SSL_CLIENT_CERT "%{SSL_CLIENT_CERT}s" + + + + SSLVerifyClient optional + SSLOptions +ExportCertData +StdEnvVars + RequestHeader set SSL_CLIENT_CERT "%{SSL_CLIENT_CERT}s" + + + + SSLVerifyClient optional + SSLOptions +ExportCertData +StdEnvVars + RequestHeader set SSL_CLIENT_CERT "%{SSL_CLIENT_CERT}s" + + {{- end }} + + ProxyPass /ejbca/ http://localhost:{{ .targetPort }}/ejbca/ keepalive=On ping=500ms retry=1 timeout=300 + + # Add ProxyPass for EST and .well-known URLs + ProxyPass /.well-known/ http://localhost:{{ .targetPort }}/.well-known/ keepalive=On ping=500ms retry=1 timeout=300 + + {{- end }} + + # + # # Disallow any HTTP method that is not HEAD, GET or POST + # RewriteEngine On + # RewriteCond %{REQUEST_METHOD} !^(HEAD|GET|POST)$ [NC] + # RewriteRule .* - [F,L] + + # # Allow encoded slashes for OCSP GET + # AllowEncodedSlashes On + + # # Proxy http requests from K8s ingress to EJBCA via port 8082 + # ProxyPass /ejbca/ http://localhost:8009/ejbca/ keepalive=On ping=500ms retry=1 timeout=300 + # +kind: ConfigMap +metadata: + name: httpd-configmap +{{- end -}} diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/ejbca-service.yaml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/ejbca-service.yaml new file mode 100644 index 0000000000..5af9eb5f98 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/ejbca-service.yaml @@ -0,0 +1,21 @@ +{{- $svcType := .Values.ejbca.service.type }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.ejbca.service.name }} + labels: + {{- include "ejbca.labels" . | nindent 4 }} +spec: + type: {{ .Values.ejbca.service.type }} + ports: + {{- range .Values.ejbca.service.ports }} + - name: {{ .name }} + port: {{ .port }} + targetPort: {{ .targetPort}} + protocol: {{ .protocol }} + {{- if contains "NodePort" $svcType }} + nodePort: {{ .nodePort }} + {{- end }} + {{- end }} + selector: + {{- include "ejbca.selectorLabels" . | nindent 4 }} diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/reverseproxy-service.yaml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/reverseproxy-service.yaml new file mode 100644 index 0000000000..d8def2f72f --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/reverseproxy-service.yaml @@ -0,0 +1,23 @@ +{{- if .Values.ejbca.reverseProxy.enabled -}} +{{- $svcType := .Values.ejbca.reverseProxy.service.type }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.ejbca.reverseProxy.service.name }} + labels: + {{- include "ejbca.labels" . | nindent 4 }} +spec: + type: {{ .Values.ejbca.reverseProxy.service.type }} + ports: + {{- range .Values.ejbca.reverseProxy.service.ports }} + - name: {{ .name }} + port: {{ .port }} + targetPort: {{ .port}} + protocol: {{ .protocol }} + {{- if contains "NodePort" $svcType }} + nodePort: {{ .nodePort }} + {{- end }} + {{- end }} + selector: + {{- include "ejbca.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/serviceaccount.yaml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/serviceaccount.yaml new file mode 100644 index 0000000000..86a7148fdb --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/templates/serviceaccount.yaml @@ -0,0 +1,47 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "ejbca.serviceAccountName" . }} + labels: + {{- include "ejbca.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + {{- include "ejbca.labels" . | nindent 4 }} + name: {{ include "ejbca.name" . }}-secret-role + namespace: {{ .Release.Namespace }} +rules: + - apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list + - watch + - create + - delete +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + {{- include "ejbca.labels" . | nindent 4 }} + name: {{ include "ejbca.name" . }}-secret-rolebinding + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "ejbca.name" . }}-secret-role +subjects: + - kind: ServiceAccount + name: {{ include "ejbca.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/values.yaml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/values.yaml new file mode 100644 index 0000000000..4bb4dd2b6f --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/ejbca/values.yaml @@ -0,0 +1,128 @@ +# Default values for ejbca. +# This is a YAML-formatted file. + +nameOverride: "" +fullnameOverride: "" + +ejbca: + replicaCount: 1 + + podAnnotations: {} + + image: + repository: keyfactor/ejbca-ce + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "latest" + imagePullSecrets: [] + + containerPorts: + - name: ejbca-http + containerPort: 8081 + protocol: TCP + - name: ejbca-https + containerPort: 8082 + protocol: TCP + + proxyHttpBind: "0.0.0.0" + logAuditToDatabase: "true" + + service: + name: ejbca-service + type: NodePort + ports: + - name: ejbca-http + port: 8081 + targetPort: ejbca-http + nodePort: 30080 + protocol: TCP + - name: ejbca-https + port: 8082 + targetPort: ejbca-https + nodePort: 30443 + protocol: TCP + + reverseProxy: + enabled: false + image: + repository: httpd + pullPolicy: IfNotPresent + tag: "2.4" + + service: + name: ejbca-rp-service + type: ClusterIP + ports: + - name: ejbca-rp-https + port: 8443 + targetPort: 8082 + protocol: TCP + + authCASecretName: managementca + tlsSecretName: ejbca-reverseproxy-tls + baseCertDir: '/usr/local/certs' + baseCaCertDir: '/usr/local/cacerts' + + volumes: [] + # - name: ejbca-eep-admininternal + # configMapName: ejbca-eep-admininternal + # mountPath: /opt/keyfactor/stage/admininternal + + extraEnvironmentVars: [] + # - name: EJBCA_EXTRA_ENV + # value: "EJBCA_EXTRA_ENV" + +database: + replicaCount: 1 + + name: ejbca + username: ejbca + password: ejbca + rootPassword: ejbca + + image: + repository: mariadb + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "latest" + imagePullSecrets: [] + + service: + name: ejbca-database-service + type: ClusterIP + port: 3306 + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +resources: {} + # limits: + # cpu: "16" + # memory: "4096Mi" + # requests: + # cpu: 1000m + # memory: "2048Mi" + +nodeSelector: {} + +tolerations: [] + +affinity: {} + diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/kind-config.yaml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/kind-config.yaml new file mode 100644 index 0000000000..3492b37a45 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/kind-config.yaml @@ -0,0 +1,8 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + image: kindest/node:v1.34.1 + extraMounts: + - hostPath: /usr/local/share/ca-certificates + containerPath: /usr/local/share/ca-certificates diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/server/kustomization.yaml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/server/kustomization.yaml new file mode 100644 index 0000000000..61ec1abdc4 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/server/kustomization.yaml @@ -0,0 +1,10 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# list of Resource Config to be Applied +resources: +- spire-server.yaml + +# namespace to deploy all Resources to +namespace: spire diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/conf/server/spire-server.yaml b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/server/spire-server.yaml new file mode 100644 index 0000000000..2a9112d839 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/conf/server/spire-server.yaml @@ -0,0 +1,144 @@ +# ConfigMap containing the SPIRE server configuration. +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + + experimental { + allow_pluggable_datastore = true + } + } + + plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["172.17.0.1:9044", "172.17.0.1:9045", "172.17.0.1:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + + KeyManager "memory" { + plugin_data = {} + } + + UpstreamAuthority "ejbca" { + plugin_data = { + hostname = "ejbca-rp-service.ejbca.svc.cluster.local:8443" + ca_cert_path = "/run/spire/ejbca/ca/ca.crt" + client_cert_path = "/run/spire/ejbca/mtls/tls.crt" + client_cert_key_path = "/run/spire/ejbca/mtls/tls.key" + ca_name = "Sub-CA" + end_entity_profile_name = "spireIntermediateCA" + certificate_profile_name = "SUBCA" + end_entity_name = "" + account_binding_id = "" + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } + +--- + +# This is the Deployment for the SPIRE server. It waits for SPIRE database to +# initialize and uses the SPIRE healthcheck command for liveness/readiness +# probes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spire-server + namespace: spire + labels: + app: spire-server +spec: + replicas: 1 + selector: + matchLabels: + app: spire-server + template: + metadata: + namespace: spire + labels: + app: spire-server + spec: + shareProcessNamespace: true + containers: + - name: spire-server + image: spire-server:latest-local + imagePullPolicy: Never + args: ["-config", "/run/spire/config/server.conf"] + ports: + - containerPort: 8081 + volumeMounts: + - name: spire-config + mountPath: /run/spire/config + readOnly: true + - name: superadmin-tls + mountPath: /run/spire/ejbca/mtls + readOnly: true + - name: subca + mountPath: /run/spire/ejbca/ca + readOnly: true + livenessProbe: + httpGet: + path: /live + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 5 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 5 + volumes: + - name: spire-config + configMap: + name: spire-server + - name: superadmin-tls + secret: + secretName: superadmin-tls + - name: subca + secret: + secretName: subca + diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/init-kubectl b/test/integration/cassandra-suites/upstream-authority-ejbca/init-kubectl new file mode 100644 index 0000000000..c27940d7a9 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/init-kubectl @@ -0,0 +1,7 @@ +#!/bin/bash + +KUBECONFIG="${RUNDIR}/kubeconfig" +if [ ! -f "${RUNDIR}/kubeconfig" ]; then + ./bin/kind get kubeconfig --name=ejbca-test > "${RUNDIR}/kubeconfig" +fi +export KUBECONFIG diff --git a/test/integration/cassandra-suites/upstream-authority-ejbca/teardown b/test/integration/cassandra-suites/upstream-authority-ejbca/teardown new file mode 100755 index 0000000000..7f09a04635 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-ejbca/teardown @@ -0,0 +1,10 @@ +#!/bin/bash + +source init-kubectl + +if [ -z "$SUCCESS" ]; then + ./bin/kubectl -nspire logs deployment/spire-server --all-containers || true +fi + +export KUBECONFIG= +./bin/kind delete cluster --name ejbca-test diff --git a/test/integration/cassandra-suites/upstream-authority-vault/00-setup-kind b/test/integration/cassandra-suites/upstream-authority-vault/00-setup-kind new file mode 100755 index 0000000000..b3c7b99550 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/00-setup-kind @@ -0,0 +1,30 @@ +#!/bin/bash + +# Create a temporary path that will be added to the PATH to avoid picking up +# binaries from the environment that aren't a version match. +mkdir -p ./bin + +KIND_PATH=./bin/kind +KUBECTL_PATH=./bin/kubectl +HELM_PATH=./bin/helm + +# Download kind at the expected version at the given path. +download-kind "${KIND_PATH}" + +# Download kubectl at the expected version. +download-kubectl "${KUBECTL_PATH}" + +# Download helm at the expected version. +download-helm "${HELM_PATH}" + +# Start the kind cluster. +# start-kind-cluster "${KIND_PATH}" vault-test +start-kind-cluster "${KIND_PATH}" vault-test ./conf/kind-config.yaml + + +# Load the given images in the cluster. +container_images=("spire-server:latest-local") +load-images "${KIND_PATH}" vault-test "${container_images[@]}" + +# Set the kubectl context. +set-kubectl-context "${KUBECTL_PATH}" kind-vault-test diff --git a/test/integration/cassandra-suites/upstream-authority-vault/01-setup-vault b/test/integration/cassandra-suites/upstream-authority-vault/01-setup-vault new file mode 100755 index 0000000000..cf5c2fbcb2 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/01-setup-vault @@ -0,0 +1,103 @@ +#!/bin/bash + +set -e -o pipefail + +source init-kubectl + +CHARTVERSION=0.23.0 + +log-info "installing hashicorp vault..." + +kubectl-exec-vault() { + ./bin/kubectl exec -n vault vault-0 -- $@ +} + +log-info "preparing certificates..." +# Prepare CSR for Vault instance +openssl ecparam -name prime256v1 -genkey -noout -out vault_key.pem +openssl req -new \ + -key vault_key.pem \ + -out vault_csr.pem \ + -subj "/C=US/O=system:nodes/CN=system:node:vault" \ + -reqexts v3 \ + -config <(cat /etc/ssl/openssl.cnf ; printf "\n[v3]\nsubjectAltName=@alt_names\n[alt_names]\nDNS.1=vault\nDNS.2=vault.vault.svc\nIP.1=127.0.0.1") +cat > csr.yaml </dev/null) ]]; do sleep 1; done' +./bin/kubectl get csr -n vault vault.svc -o jsonpath='{.status.certificate}' | openssl base64 -d -A -out vault.pem +./bin/kubectl config view --raw --minify --flatten -o jsonpath='{.clusters[].cluster.certificate-authority-data}' \ + | base64 -d > vault_ca.pem +./bin/kubectl create secret generic vault-tls -n vault \ + --from-file=vault_key.pem=vault_key.pem \ + --from-file=vault.pem=vault.pem \ + --from-file=vault_ca.pem=vault_ca.pem + +./bin/helm repo add hashicorp https://helm.releases.hashicorp.com +./bin/helm install vault hashicorp/vault --namespace vault --version $CHARTVERSION -f conf/helm-values.yaml +./bin/kubectl wait -n kube-system --for=condition=available deployment --all --timeout=90s +./bin/kubectl wait pods -n vault --for=jsonpath='{.status.phase}'=Running vault-0 --timeout=90s + +# Initialize and unseal +log-info "initializing hashicorp vault..." +kubectl-exec-vault vault operator init -key-shares=1 -key-threshold=1 -format=json > cluster-keys.json +VAULT_UNSEAL_KEY=$(cat cluster-keys.json | jq -r ".unseal_keys_b64[]") +kubectl-exec-vault vault operator unseal $VAULT_UNSEAL_KEY +./bin/kubectl wait pods -n vault --for=condition=Ready vault-0 --timeout=60s +VAULT_ROOT_TOKEN=$(cat cluster-keys.json | jq -r ".root_token") +kubectl-exec-vault vault login $VAULT_ROOT_TOKEN > /dev/null + +./bin/kubectl cp -n vault cert_auth_ca.pem vault-0:/tmp/. +./bin/kubectl cp -n vault conf/configure-pki-secret-engine.sh vault-0:/tmp/. +./bin/kubectl cp -n vault conf/spire.hcl vault-0:tmp/. +./bin/kubectl cp -n vault conf/configure-auth-method.sh vault-0:/tmp/. + +# Configure Vault +log-info "configuring pki secret engine..." +kubectl-exec-vault /tmp/configure-pki-secret-engine.sh +log-info "configuring auth methods..." +kubectl-exec-vault /tmp/configure-auth-method.sh + diff --git a/test/integration/cassandra-suites/upstream-authority-vault/02-deploy-spire-and-verify-auth b/test/integration/cassandra-suites/upstream-authority-vault/02-deploy-spire-and-verify-auth new file mode 100755 index 0000000000..ad06fa9ecb --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/02-deploy-spire-and-verify-auth @@ -0,0 +1,46 @@ +#!/bin/bash + +set -e -o pipefail + +./bin/kubectl create namespace spire +./bin/kubectl create secret -n spire generic vault-tls \ + --from-file=vault_ca.pem=vault_ca.pem + +# Verify AppRole Auth +log-info "verifying approle auth..." +APPROLE_ID=$(./bin/kubectl exec -n vault vault-0 -- vault read --format json auth/approle/role/spire/role-id | jq -r .data.role_id) +SECRET_ID=$(./bin/kubectl exec -n vault vault-0 -- vault write --format json -f auth/approle/role/spire/secret-id | jq -r .data.secret_id) +./bin/kubectl create secret -n spire generic vault-credential \ + --from-literal=approle_id=$APPROLE_ID \ + --from-literal=secret_id=$SECRET_ID +./bin/kubectl apply -k ./conf/server/approle-auth +./bin/kubectl wait pods -n spire -l app=spire-server --for=condition=Ready --timeout=120s +./bin/kubectl delete -k ./conf/server/approle-auth +./bin/kubectl delete secret -n spire vault-credential +./bin/kubectl wait pods -n spire -l app=spire-server --for=delete --timeout=120s + +# Verify K8s Auth +log-info "verifying k8s auth..." +./bin/kubectl apply -k ./conf/server/k8s-auth +./bin/kubectl wait pods -n spire -l app=spire-server --for=condition=Ready --timeout=120s +./bin/kubectl delete -k ./conf/server/k8s-auth +./bin/kubectl wait pods -n spire -l app=spire-server --for=delete --timeout=120s + +# Verify Cert Auth +log-info "verifying cert auth..." +./bin/kubectl create secret -n spire generic vault-credential \ + --from-file=client.pem=client.pem \ + --from-file=client_key.pem=client_key.pem +./bin/kubectl apply -k ./conf/server/cert-auth +./bin/kubectl wait pods -n spire -l app=spire-server --for=condition=Ready --timeout=120s +./bin/kubectl delete -k ./conf/server/cert-auth +./bin/kubectl delete secret -n spire vault-credential +./bin/kubectl wait pods -n spire -l app=spire-server --for=delete --timeout=120s + +# Verify Token Auth +log-info "verifying token auth..." +TOKEN=$(./bin/kubectl exec -n vault vault-0 -- vault token create -policy=spire -ttl=1m -field=token) +./bin/kubectl create secret -n spire generic vault-credential \ + --from-literal=token=$TOKEN +./bin/kubectl apply -k ./conf/server/token-auth +./bin/kubectl wait pods -n spire -l app=spire-server --for=condition=Ready --timeout=120s diff --git a/test/integration/cassandra-suites/upstream-authority-vault/03-verify-ca b/test/integration/cassandra-suites/upstream-authority-vault/03-verify-ca new file mode 100755 index 0000000000..32ca9d9bce --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/03-verify-ca @@ -0,0 +1,40 @@ +#!/bin/bash + +set -e -o pipefail + +source init-kubectl + +expURI[0]="URI:spiffe://example.org/ns/foo/sa/bar" +expURI[1]="URI:spiffe://example.org" +expURI[2]="URI:spiffe://intermediate-ca-vault" + +expRootURI="URI:spiffe://root-ca" + +log-debug "verifying CA..." + +mintx509svid_out=mintx509svid-out.txt +./bin/kubectl exec -n spire $(./bin/kubectl get pod -n spire -o name) -- /opt/spire/bin/spire-server x509 mint -spiffeID spiffe://example.org/ns/foo/sa/bar > $mintx509svid_out + +svid=svid.pem +sed -n '/-----BEGIN CERTIFICATE-----/,/^$/{/^$/q; p;}' $mintx509svid_out > $svid + +bundle=bundle.pem +sed -n '/Root CAs:/,/^$/p' $mintx509svid_out | sed -n '/-----BEGIN CERTIFICATE-----/,/^$/{/^$/q; p;}' > $bundle + +idx=0 +uris=($(openssl crl2pkcs7 -nocrl -certfile $svid | openssl pkcs7 -print_certs -text -noout | grep "URI:spiffe:")) +for uri in ${uris[@]}; do + if [[ "$uri" == "${expURI[${idx}]}" ]]; then + log-info "${expURI[${idx}]} is verified" + else + fail-now "exp=${expURI[${idx}]}, got=$uri" + fi + idx=`expr $idx + 1` +done + +rootURI=($(openssl x509 -in $bundle -noout -text | grep "URI:spiffe:")) +if [[ "$rootURI" == "$expRootURI" ]]; then + log-info "$expRootURI is verified" +else + fail-now "exp=$expRootURI, got=$rootURI" +fi diff --git a/test/integration/cassandra-suites/upstream-authority-vault/04-verify-token-renewal.sh b/test/integration/cassandra-suites/upstream-authority-vault/04-verify-token-renewal.sh new file mode 100755 index 0000000000..3572093460 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/04-verify-token-renewal.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -eo pipefail + +log-debug "verifying token renewal..." + +timeout=$(gdate -ud "1 minute 30 second" +%s) +count=0 + +while [ $(gdate -u +%s) -lt $timeout ]; do + count=`./bin/kubectl logs -n spire $(./bin/kubectl get pod -n spire -o name) | echo "$(grep "Successfully renew auth token" || [[ $? == 1 ]])" | wc -l` + if [ $count -ge 2 ]; then + log-info "token renewal is verified" + exit 0 + fi + sleep 10 +done + +fail-now "expected number of token renewal log not found" diff --git a/test/integration/cassandra-suites/upstream-authority-vault/README.md b/test/integration/cassandra-suites/upstream-authority-vault/README.md new file mode 100644 index 0000000000..89e80d5ead --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/README.md @@ -0,0 +1,21 @@ +# UpstreamAuthority vault plugin suite + +## Description + +This suite sets up a Kubernetes cluster using [Kind](https://kind.sigs.k8s.io), +installs HashiCorp Vault. It then asserts the following: + +PKI tree + +```mermaid +flowchart TD + subgraph Vault PKI Secret Engine + A[Vault PKI Root CA] --> B[Vault PKI Intermediate CA] + end + B --> C[SPIRE Intermediate CA] + C --> D[Leaf X509 SVID] +``` + +* SPIRE server successfully requests an intermediate CA from the referenced Vault PKI Secret Engine +* Verifies that Auth Methods are configured successfully +* Verifies that obtained identities have been signed by that intermediate CA, and the Vault PKI Secret Engine is the root of trust diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/configure-auth-method.sh b/test/integration/cassandra-suites/upstream-authority-vault/conf/configure-auth-method.sh new file mode 100755 index 0000000000..82bb9bc22f --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/configure-auth-method.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +set -e -o pipefail + +# Create Policy +vault policy write spire /tmp/spire.hcl + +# Configure Vault Auth Method +vault auth enable approle +vault write auth/approle/role/spire \ + secret_id_ttl=120m \ + token_ttl=1m \ + policies="spire" + +# Configure K8s Auth Method +vault auth enable kubernetes +vault write auth/kubernetes/config kubernetes_host=https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT_HTTPS +vault write auth/kubernetes/role/my-role \ + bound_service_account_names=spire-server \ + bound_service_account_namespaces=spire \ + token_ttl=1m \ + policies=spire + +# Configure Cert Auth Method +vault auth enable cert +vault write auth/cert/certs/my-role \ + display_name=spire \ + token_ttl=1m \ + policies=spire \ + certificate=@/tmp/cert_auth_ca.pem diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/configure-pki-secret-engine.sh b/test/integration/cassandra-suites/upstream-authority-vault/conf/configure-pki-secret-engine.sh new file mode 100755 index 0000000000..8bd5b942be --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/configure-pki-secret-engine.sh @@ -0,0 +1,33 @@ +#!/bin/sh + +set -e -o pipefail + +# Configure Root CA +vault secrets enable pki +vault secrets tune -max-lease-ttl=8760h pki +vault write pki/root/generate/internal \ + common_name="root-ca" \ + uri_sans="spiffe://root-ca" \ + exclude_cn_from_sans=true \ + ttl=8760h > /dev/null +vault write pki/config/urls \ + issuing_certificates="http://vault.vault.svc:8200/v1/pki/ca" \ + crl_distribution_points="http://vault.vault.svc:8200/v1/pki/crl" + +# Configure Intermediate CA +vault secrets enable -path=pki_int pki +vault secrets tune -max-lease-ttl=43800h pki_int +vault write --field=csr pki_int/intermediate/generate/internal \ + common_name="intermediate-ca-vault" \ + ttl=43800h > /tmp/pki_int.csr +vault write --field=certificate pki/root/sign-intermediate \ + csr=@/tmp/pki_int.csr \ + common_name="intermediate-ca-vault" \ + uri_sans="spiffe://intermediate-ca-vault" \ + exclude_cn_from_sans=true \ + format=pem_bundle \ + ttl=43800h > /tmp/signed_certificate.pem +vault write pki_int/intermediate/set-signed certificate=@/tmp/signed_certificate.pem > /dev/null +vault write pki_int/config/urls \ + issuing_certificates="http://vault.vault.svc:8200/v1/pki_int/ca" \ + crl_distribution_points="http://vault.vault.svc:8200/v1/pki_int/crl" diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/helm-values.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/helm-values.yaml new file mode 100644 index 0000000000..6607a9cbad --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/helm-values.yaml @@ -0,0 +1,33 @@ +global: + enabled: true + tlsDisable: false +server: + extraEnvironmentVars: + VAULT_CACERT: /vault/userconfig/vault-tls/vault_ca.pem + VAULT_TLSCERT: /vault/userconfig/vault-tls/vault.pem + VAULT_TLSKEY: /vault/userconfig/vault-tls/vault_key.pem + volumes: + - name: userconfig-vault-tls + secret: + defaultMode: 420 + secretName: vault-tls + volumeMounts: + - mountPath: /vault/userconfig/vault-tls + name: userconfig-vault-tls + readOnly: true + standalone: + enabled: "-" + config: | + listener "tcp" { + address = "[::]:8200" + cluster_address = "[::]:8201" + + tls_cert_file = "/vault/userconfig/vault-tls/vault.pem" + tls_key_file = "/vault/userconfig/vault-tls/vault_key.pem" + + tls_disable_client_certs = false + } + + storage "file" { + path = "/vault/data" + } diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/kind-config.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/kind-config.yaml new file mode 100644 index 0000000000..3492b37a45 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/kind-config.yaml @@ -0,0 +1,8 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + image: kindest/node:v1.34.1 + extraMounts: + - hostPath: /usr/local/share/ca-certificates + containerPath: /usr/local/share/ca-certificates diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/approle-auth/kustomization.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/approle-auth/kustomization.yaml new file mode 100644 index 0000000000..4df53c4ef2 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/approle-auth/kustomization.yaml @@ -0,0 +1,9 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- ../base +patches: +- path: spire-server-configmap.yaml +- path: spire-server-deployment.yaml diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/approle-auth/spire-server-configmap.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/approle-auth/spire-server-configmap.yaml new file mode 100644 index 0000000000..a4f26ff3ff --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/approle-auth/spire-server-configmap.yaml @@ -0,0 +1,92 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + + experimental { + allow_pluggable_datastore = true + } + } + + plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["172.17.0.1:9044", "172.17.0.1:9045", "172.17.0.1:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + + NodeAttestor "k8s_psat" { + plugin_data { + clusters = { + "example-cluster" = { + service_account_allow_list = ["spire:spire-agent"] + } + } + } + } + + KeyManager "disk" { + plugin_data { + keys_path = "/run/spire/data/keys.json" + } + } + + UpstreamAuthority "vault" { + plugin_data { + vault_addr="https://vault.vault.svc:8200/" + ca_cert_path="/run/spire/vault/vault_ca.pem" + pki_mount_point="pki_int" + approle_auth {} + } + } + + Notifier "k8sbundle" { + plugin_data { + # This plugin updates the bundle.crt value in the spire:spire-bundle + # ConfigMap by default, so no additional configuration is necessary. + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/approle-auth/spire-server-deployment.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/approle-auth/spire-server-deployment.yaml new file mode 100644 index 0000000000..42d04eccd8 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/approle-auth/spire-server-deployment.yaml @@ -0,0 +1,26 @@ +# This is the Deployment for the SPIRE server. It waits for SPIRE database to +# initialize and uses the SPIRE healthcheck command for liveness/readiness +# probes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spire-server + namespace: spire + labels: + app: spire-server +spec: + template: + spec: + containers: + - name: spire-server + env: + - name: VAULT_APPROLE_ID + valueFrom: + secretKeyRef: + name: vault-credential + key: approle_id + - name: VAULT_APPROLE_SECRET_ID + valueFrom: + secretKeyRef: + name: vault-credential + key: secret_id diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/kustomization.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/kustomization.yaml new file mode 100644 index 0000000000..ccaa8a7b40 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/kustomization.yaml @@ -0,0 +1,18 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# list of Resource Config to be Applied +resources: +- spire-server-serviceaccount.yaml +- spire-server-clusterrole.yaml +- spire-server-clusterrolebinding.yaml +- spire-server-role.yaml +- spire-server-rolebinding.yaml +- spire-server-bundle-configmap.yaml +- spire-server-configmap.yaml +- spire-server-deployment.yaml +- spire-server-service.yaml + +# namespace to deploy all Resources to +namespace: spire diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-bundle-configmap.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-bundle-configmap.yaml new file mode 100644 index 0000000000..818329953a --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-bundle-configmap.yaml @@ -0,0 +1,9 @@ +# ConfigMap containing the latest trust bundle for the trust domain. It is +# updated by SPIRE using the k8sbundle notifier plugin. SPIRE agents mount +# this config map and use the certificate to bootstrap trust with the SPIRE +# server during attestation. +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-bundle + namespace: spire diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-clusterrole.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-clusterrole.yaml new file mode 100644 index 0000000000..44150812b8 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-clusterrole.yaml @@ -0,0 +1,9 @@ +# Required cluster role to allow spire-server to query k8s API server +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-cluster-role +rules: + - apiGroups: [""] + resources: ["pods", "nodes"] + verbs: ["get", "list", "watch"] diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-clusterrolebinding.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-clusterrolebinding.yaml new file mode 100644 index 0000000000..1ede729562 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-clusterrolebinding.yaml @@ -0,0 +1,13 @@ +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-cluster-role-binding + namespace: spire +subjects: + - kind: ServiceAccount + name: spire-server + namespace: spire +roleRef: + kind: ClusterRole + name: spire-server-cluster-role + apiGroup: rbac.authorization.k8s.io diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-configmap.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-configmap.yaml new file mode 100644 index 0000000000..07d3bf089f --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-configmap.yaml @@ -0,0 +1,91 @@ +# ConfigMap containing the SPIRE server configuration. +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + + experimental { + allow_pluggable_datastore = true + } + } + + plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["172.17.0.1:9044", "172.17.0.1:9045", "172.17.0.1:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + + NodeAttestor "k8s_psat" { + plugin_data { + clusters = { + "example-cluster" = { + service_account_allow_list = ["spire:spire-agent"] + } + } + } + } + + KeyManager "disk" { + plugin_data { + keys_path = "/run/spire/data/keys.json" + } + } + + UpstreamAuthority "vault" { + plugin_data { + vault_addr="http://vault.vault.svc:8200/" + token_auth {} + } + } + + Notifier "k8sbundle" { + plugin_data { + # This plugin updates the bundle.crt value in the spire:spire-bundle + # ConfigMap by default, so no additional configuration is necessary. + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-deployment.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-deployment.yaml new file mode 100644 index 0000000000..8dc4aa1fc2 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-deployment.yaml @@ -0,0 +1,56 @@ +# This is the Deployment for the SPIRE server. It waits for SPIRE database to +# initialize and uses the SPIRE healthcheck command for liveness/readiness +# probes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spire-server + namespace: spire + labels: + app: spire-server +spec: + replicas: 1 + selector: + matchLabels: + app: spire-server + template: + metadata: + namespace: spire + labels: + app: spire-server + spec: + serviceAccountName: spire-server + shareProcessNamespace: true + containers: + - name: spire-server + image: spire-server:latest-local + imagePullPolicy: Never + args: ["-config", "/run/spire/config/server.conf"] + ports: + - containerPort: 8081 + volumeMounts: + - name: spire-config + mountPath: /run/spire/config + readOnly: true + - name: vault-tls + mountPath: "/run/spire/vault" + readOnly: true + livenessProbe: + httpGet: + path: /live + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 5 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 5 + volumes: + - name: spire-config + configMap: + name: spire-server + - name: vault-tls + secret: + secretName: vault-tls diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-role.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-role.yaml new file mode 100644 index 0000000000..e4ec87d6ec --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-role.yaml @@ -0,0 +1,26 @@ +# Role for the SPIRE server +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + namespace: spire + name: spire-server-role +rules: + # allow "get" access to pods (to resolve selectors for PSAT attestation) + - apiGroups: [""] + resources: ["pods"] + verbs: ["get"] + # allow access to "get" and "patch" the spire-bundle ConfigMap (for SPIRE + # agent bootstrapping, see the spire-bundle ConfigMap below) + - apiGroups: [""] + resources: ["configmaps"] + resourceNames: ["spire-bundle"] + verbs: ["get", "patch"] + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["create"] + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["create", "update", "get"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create"] diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-rolebinding.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-rolebinding.yaml new file mode 100644 index 0000000000..810bee48f5 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-rolebinding.yaml @@ -0,0 +1,15 @@ +# RoleBinding granting the spire-server-role to the SPIRE server +# service account. +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-role-binding + namespace: spire +subjects: + - kind: ServiceAccount + name: spire-server + namespace: spire +roleRef: + kind: Role + name: spire-server-role + apiGroup: rbac.authorization.k8s.io diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-service.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-service.yaml new file mode 100644 index 0000000000..e672b4c6b2 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-service.yaml @@ -0,0 +1,15 @@ +# Service definition for SPIRE server defining the gRPC port. +apiVersion: v1 +kind: Service +metadata: + name: spire-server + namespace: spire +spec: + type: NodePort + ports: + - name: grpc + port: 8081 + targetPort: 8081 + protocol: TCP + selector: + app: spire-server diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-serviceaccount.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-serviceaccount.yaml new file mode 100644 index 0000000000..fddff08c6c --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/base/spire-server-serviceaccount.yaml @@ -0,0 +1,6 @@ +# ServiceAccount used by the SPIRE server. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: spire-server + namespace: spire diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/cert-auth/kustomization.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/cert-auth/kustomization.yaml new file mode 100644 index 0000000000..4df53c4ef2 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/cert-auth/kustomization.yaml @@ -0,0 +1,9 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- ../base +patches: +- path: spire-server-configmap.yaml +- path: spire-server-deployment.yaml diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/cert-auth/spire-server-configmap.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/cert-auth/spire-server-configmap.yaml new file mode 100644 index 0000000000..c76b810f2f --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/cert-auth/spire-server-configmap.yaml @@ -0,0 +1,95 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + + experimental { + allow_pluggable_datastore = true + } + } + + plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["172.17.0.1:9044", "172.17.0.1:9045", "172.17.0.1:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + + NodeAttestor "k8s_psat" { + plugin_data { + clusters = { + "example-cluster" = { + service_account_allow_list = ["spire:spire-agent"] + } + } + } + } + + KeyManager "disk" { + plugin_data { + keys_path = "/run/spire/data/keys.json" + } + } + + UpstreamAuthority "vault" { + plugin_data { + vault_addr="https://vault.vault.svc:8200/" + ca_cert_path="/run/spire/vault/vault_ca.pem" + pki_mount_point="pki_int" + cert_auth { + client_cert_path="/run/spire/vault-auth/client.pem" + client_key_path="/run/spire/vault-auth/client_key.pem" + } + } + } + + Notifier "k8sbundle" { + plugin_data { + # This plugin updates the bundle.crt value in the spire:spire-bundle + # ConfigMap by default, so no additional configuration is necessary. + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/cert-auth/spire-server-deployment.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/cert-auth/spire-server-deployment.yaml new file mode 100644 index 0000000000..36f86460bc --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/cert-auth/spire-server-deployment.yaml @@ -0,0 +1,23 @@ +# This is the Deployment for the SPIRE server. It waits for SPIRE database to +# initialize and uses the SPIRE healthcheck command for liveness/readiness +# probes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spire-server + namespace: spire + labels: + app: spire-server +spec: + template: + spec: + volumes: + - name: vault-credential + secret: + secretName: vault-credential + containers: + - name: spire-server + volumeMounts: + - name: vault-credential + mountPath: "/run/spire/vault-auth" + readOnly: true diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/k8s-auth/kustomization.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/k8s-auth/kustomization.yaml new file mode 100644 index 0000000000..11b5ad7de2 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/k8s-auth/kustomization.yaml @@ -0,0 +1,8 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- ../base +patches: +- path: spire-server-configmap.yaml diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/k8s-auth/spire-server-configmap.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/k8s-auth/spire-server-configmap.yaml new file mode 100644 index 0000000000..048f8bee0d --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/k8s-auth/spire-server-configmap.yaml @@ -0,0 +1,95 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + + experimental { + allow_pluggable_datastore = true + } + } + + plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["172.17.0.1:9044", "172.17.0.1:9045", "172.17.0.1:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + + NodeAttestor "k8s_psat" { + plugin_data { + clusters = { + "example-cluster" = { + service_account_allow_list = ["spire:spire-agent"] + } + } + } + } + + KeyManager "disk" { + plugin_data { + keys_path = "/run/spire/data/keys.json" + } + } + + UpstreamAuthority "vault" { + plugin_data { + vault_addr="https://vault.vault.svc:8200/" + ca_cert_path="/run/spire/vault/vault_ca.pem" + pki_mount_point="pki_int" + k8s_auth { + k8s_auth_role_name = "my-role" + token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token" + } + } + } + + Notifier "k8sbundle" { + plugin_data { + # This plugin updates the bundle.crt value in the spire:spire-bundle + # ConfigMap by default, so no additional configuration is necessary. + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/token-auth/kustomization.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/token-auth/kustomization.yaml new file mode 100644 index 0000000000..4df53c4ef2 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/token-auth/kustomization.yaml @@ -0,0 +1,9 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- ../base +patches: +- path: spire-server-configmap.yaml +- path: spire-server-deployment.yaml diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/token-auth/spire-server-configmap.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/token-auth/spire-server-configmap.yaml new file mode 100644 index 0000000000..4e6cd605f6 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/token-auth/spire-server-configmap.yaml @@ -0,0 +1,92 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + + experimental { + allow_pluggable_datastore = true + } + } + + plugins { + DataStore "cassandra" { + max_grpc_message_size = 1000000000 # 1GB + + plugin_data { + hosts = ["172.17.0.1:9044", "172.17.0.1:9045", "172.17.0.1:9046"] + keyspace = "spire" + read_consistency = "LOCAL_QUORUM" + write_consistency = "QUORUM" + skip_migrations = false + replication_strategy = "NetworkTopologyStrategy" + network_topology_strategy_replication_factors = { + "dc1" = "3" + } + simple_strategy_replication_factor = 1 + max_connection_attempts = 10 + driver_log_level = "DEBUG" + connect_timeout_ms = "10000" + read_timeout_ms = "10000" + write_timeout_ms = "11000" + + username = "cassandra" + password = "cassandra" + } + } + + NodeAttestor "k8s_psat" { + plugin_data { + clusters = { + "example-cluster" = { + service_account_allow_list = ["spire:spire-agent"] + } + } + } + } + + KeyManager "disk" { + plugin_data { + keys_path = "/run/spire/data/keys.json" + } + } + + UpstreamAuthority "vault" { + plugin_data { + vault_addr="https://vault.vault.svc:8200/" + ca_cert_path="/run/spire/vault/vault_ca.pem" + pki_mount_point="pki_int" + token_auth {} + } + } + + Notifier "k8sbundle" { + plugin_data { + # This plugin updates the bundle.crt value in the spire:spire-bundle + # ConfigMap by default, so no additional configuration is necessary. + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/server/token-auth/spire-server-deployment.yaml b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/token-auth/spire-server-deployment.yaml new file mode 100644 index 0000000000..30a6216eda --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/server/token-auth/spire-server-deployment.yaml @@ -0,0 +1,21 @@ +# This is the Deployment for the SPIRE server. It waits for SPIRE database to +# initialize and uses the SPIRE healthcheck command for liveness/readiness +# probes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spire-server + namespace: spire + labels: + app: spire-server +spec: + template: + spec: + containers: + - name: spire-server + env: + - name: VAULT_TOKEN + valueFrom: + secretKeyRef: + name: vault-credential + key: token diff --git a/test/integration/cassandra-suites/upstream-authority-vault/conf/spire.hcl b/test/integration/cassandra-suites/upstream-authority-vault/conf/spire.hcl new file mode 100644 index 0000000000..c60d7f8feb --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/conf/spire.hcl @@ -0,0 +1,6 @@ +path "pki/root/sign-intermediate" { + capabilities = ["update"] +} +path "pki_int/root/sign-intermediate" { + capabilities = ["update"] +} diff --git a/test/integration/cassandra-suites/upstream-authority-vault/init-kubectl b/test/integration/cassandra-suites/upstream-authority-vault/init-kubectl new file mode 100644 index 0000000000..a167777059 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/init-kubectl @@ -0,0 +1,8 @@ +#!/bin/bash + +KUBECONFIG="${RUNDIR}/kubeconfig" +if [ ! -f "${RUNDIR}/kubeconfig" ]; then + ./bin/kind get kubeconfig --name=vault-test > "${RUNDIR}/kubeconfig" +fi +export KUBECONFIG + diff --git a/test/integration/cassandra-suites/upstream-authority-vault/teardown b/test/integration/cassandra-suites/upstream-authority-vault/teardown new file mode 100755 index 0000000000..83c7ae74e5 --- /dev/null +++ b/test/integration/cassandra-suites/upstream-authority-vault/teardown @@ -0,0 +1,10 @@ +#!/bin/bash + +source init-kubectl + +if [ -z "$SUCCESS" ]; then + ./bin/kubectl -nspire logs deployment/spire-server --all-containers || true +fi + +export KUBECONFIG= +./bin/kind delete cluster --name vault-test diff --git a/test/integration/common b/test/integration/common index 8bd06dcb37..ce0d6270ab 100644 --- a/test/integration/common +++ b/test/integration/common @@ -101,7 +101,7 @@ fingerprint() { check-server-started() { # Check at most 30 times (with one second in between) that the server has # successfully started. - MAXCHECKS=30 + MAXCHECKS=45 CHECKINTERVAL=1 for ((i=1;i<=MAXCHECKS;i++)); do log-info "checking for starting server APIs ($i of $MAXCHECKS max)..." @@ -135,7 +135,7 @@ check-log-line() { check-synced-entry() { # Check at most 30 times (with one second in between) that the agent has # successfully synced down the workload entry. - MAXCHECKS=30 + MAXCHECKS=60 CHECKINTERVAL=1 for ((i=1;i<=MAXCHECKS;i++)); do log-info "checking for synced entry ($i of $MAXCHECKS max)..." @@ -197,6 +197,7 @@ build-mashup-image() { FROM spire-agent:latest-local as spire-agent FROM envoyproxy/envoy:${ENVOY_IMAGE_TAG} AS envoy-agent-mashup COPY --from=spire-agent /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent +RUN update-ca-certificates RUN apt-get update RUN apt-get install -y dumb-init RUN apt-get install -y supervisor @@ -342,6 +343,20 @@ start-kind-cluster() { "${kind_path}" create cluster --name "${kind_name}" --config "${kind_config_path}" --image "${K8SIMAGE}" || fail-now "unable to create cluster" } +start-g-kind-cluster() { + K8SIMAGE=${K8SIMAGE:-kindest/node:v1.31.12} + + local kind_path=$1 + local kind_name=$2 + local kind_config_path=$3 + + log-info "starting cluster..." + HTTP_PROXY='http://host.docker.internal:9000' HTTPS_PROXY='http://host.docker.internal:9000' NO_PROXY='127.0.0.1,localhost' http_proxy='http://host.docker.internal:9000' https_proxy='http://host.docker.internal:9000' no_proxy='127.0.0.1,localhost,.internal,.svc,.svc.cluster.local' "${kind_path}" create cluster --name "${kind_name}" --config "${kind_config_path}" --image "${K8SIMAGE}" || fail-now "unable to create cluster" + for node in $(kind get nodes --name $kind_name); do + docker exec $(docker ps --filter name=^/$node\$ -q) update-ca-certificates + done + } + load-images() { local kind_path=$1; shift local kind_name=$1; shift diff --git a/test/integration/setup/cassandra-multinode/.env b/test/integration/setup/cassandra-multinode/.env new file mode 100644 index 0000000000..96d54b56ed --- /dev/null +++ b/test/integration/setup/cassandra-multinode/.env @@ -0,0 +1,4 @@ +HTTP_PROXY= +HTTPS_PROXY= +http_proxy= +https_proxy= \ No newline at end of file diff --git a/test/integration/setup/cassandra-multinode/3-node-1-dc/node-1.yml b/test/integration/setup/cassandra-multinode/3-node-1-dc/node-1.yml new file mode 100755 index 0000000000..37ea6c387d --- /dev/null +++ b/test/integration/setup/cassandra-multinode/3-node-1-dc/node-1.yml @@ -0,0 +1,13 @@ +native_transport_port: 9044 +cluster_name: spire_test +endpoint_snitch: GossipingPropertyFileSnitch +listen_address: 172.21.0.3 +rpc_address: 0.0.0.0 +broadcast_rpc_address: 127.0.0.1 +commitlog_sync: periodic +commitlog_sync_period: 10s +partitioner: org.apache.cassandra.dht.Murmur3Partitioner +seed_provider: + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + - seeds: "172.21.0.3:7000" \ No newline at end of file diff --git a/test/integration/setup/cassandra-multinode/3-node-1-dc/node-2.yml b/test/integration/setup/cassandra-multinode/3-node-1-dc/node-2.yml new file mode 100755 index 0000000000..a36802619d --- /dev/null +++ b/test/integration/setup/cassandra-multinode/3-node-1-dc/node-2.yml @@ -0,0 +1,13 @@ +native_transport_port: 9045 +cluster_name: spire_test +endpoint_snitch: GossipingPropertyFileSnitch +listen_address: 172.21.0.4 +rpc_address: 0.0.0.0 +broadcast_rpc_address: 127.0.0.1 +commitlog_sync: periodic +commitlog_sync_period: 10s +partitioner: org.apache.cassandra.dht.Murmur3Partitioner +seed_provider: + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + - seeds: "172.21.0.3:7000" \ No newline at end of file diff --git a/test/integration/setup/cassandra-multinode/3-node-1-dc/node-3.yml b/test/integration/setup/cassandra-multinode/3-node-1-dc/node-3.yml new file mode 100755 index 0000000000..ca0b75b866 --- /dev/null +++ b/test/integration/setup/cassandra-multinode/3-node-1-dc/node-3.yml @@ -0,0 +1,13 @@ +native_transport_port: 9046 +cluster_name: spire_test +endpoint_snitch: GossipingPropertyFileSnitch +listen_address: 172.21.0.5 +rpc_address: 0.0.0.0 +broadcast_rpc_address: 127.0.0.1 +commitlog_sync: periodic +commitlog_sync_period: 10s +partitioner: org.apache.cassandra.dht.Murmur3Partitioner +seed_provider: + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + - seeds: "172.21.0.3:7000" \ No newline at end of file diff --git a/test/integration/setup/cassandra-multinode/3-node-1-dc/rackdc.node1.properties b/test/integration/setup/cassandra-multinode/3-node-1-dc/rackdc.node1.properties new file mode 100644 index 0000000000..29474553e2 --- /dev/null +++ b/test/integration/setup/cassandra-multinode/3-node-1-dc/rackdc.node1.properties @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# These properties are used with GossipingPropertyFileSnitch and will +# indicate the rack and dc for this node +dc=dc1 +rack=rack1 + +# Add a suffix to a datacenter name. Used by all cloud-based snitches. +#dc_suffix= + +# Uncomment the following line to make this snitch prefer the internal ip when possible, as the Ec2MultiRegionSnitch does. +# prefer_local=true + +# Datacenter and rack naming convention used by the Ec2Snitch and Ec2MultiRegionSnitch. +# Options are: +# legacy : datacenter name is the part of the availability zone name preceding the last "-" +# when the zone ends in -1 and includes the number if not -1. Rack is the portion of +# the availability zone name following the last "-". +# Examples: us-west-1a => dc: us-west, rack: 1a; us-west-2b => dc: us-west-2, rack: 2b; +# YOU MUST USE THIS VALUE IF YOU ARE UPGRADING A PRE-4.0 CLUSTER +# standard : Default value. datacenter name is the standard AWS region name, including the number. +# rack name is the region plus the availability zone letter. +# Examples: us-west-1a => dc: us-west-1, rack: us-west-1a; us-west-2b => dc: us-west-2, rack: us-west-2b; +# ec2_naming_scheme=standard +# +# Type of AWS IMDS, value might be either "v1" or "v2". Defaults to "v2". +# ec2_metadata_type=v2 +# If AWS IMDS of v2 is configured, ec2_metadata_token_ttl_seconds says how many seconds a token will be valid until +# it is refreshed. Defaults to 21600. Can not be smaller than 30 and bigger than 21600. Has to be an integer. +# ec2_metadata_token_ttl_seconds=21600 + +# AzureSnitch +# Options are: +# Version of API to talk to. When not set, defaults to '2021-12-13'. +# azure_api_version=2021-12-13 + +# For all cloud-based snitches, there are following options available: +# +# Property to change metadata service url for a cloud-based snitch. The endpoint of a particular +# snitch will be appended to this property. A user is not normally using this property, it is here +# for tweaking the url of a service itself, e.g. for testing purposes. +# metadata_url=http://some/service +# +# Sets a specified timeout value, in duration format, to be used when opening a communications link to metadata service, +# referenced by an URLConnection. The timeout of zero (0s) is interpreted as an infinite timeout. +# Defaults to 30 seconds. +# metadata_request_timeout=30s \ No newline at end of file diff --git a/test/integration/setup/cassandra-multinode/3-node-1-dc/rackdc.node2.properties b/test/integration/setup/cassandra-multinode/3-node-1-dc/rackdc.node2.properties new file mode 100644 index 0000000000..0e705caa22 --- /dev/null +++ b/test/integration/setup/cassandra-multinode/3-node-1-dc/rackdc.node2.properties @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# These properties are used with GossipingPropertyFileSnitch and will +# indicate the rack and dc for this node +dc=dc1 +rack=rack2 + +# Add a suffix to a datacenter name. Used by all cloud-based snitches. +#dc_suffix= + +# Uncomment the following line to make this snitch prefer the internal ip when possible, as the Ec2MultiRegionSnitch does. +# prefer_local=true + +# Datacenter and rack naming convention used by the Ec2Snitch and Ec2MultiRegionSnitch. +# Options are: +# legacy : datacenter name is the part of the availability zone name preceding the last "-" +# when the zone ends in -1 and includes the number if not -1. Rack is the portion of +# the availability zone name following the last "-". +# Examples: us-west-1a => dc: us-west, rack: 1a; us-west-2b => dc: us-west-2, rack: 2b; +# YOU MUST USE THIS VALUE IF YOU ARE UPGRADING A PRE-4.0 CLUSTER +# standard : Default value. datacenter name is the standard AWS region name, including the number. +# rack name is the region plus the availability zone letter. +# Examples: us-west-1a => dc: us-west-1, rack: us-west-1a; us-west-2b => dc: us-west-2, rack: us-west-2b; +# ec2_naming_scheme=standard +# +# Type of AWS IMDS, value might be either "v1" or "v2". Defaults to "v2". +# ec2_metadata_type=v2 +# If AWS IMDS of v2 is configured, ec2_metadata_token_ttl_seconds says how many seconds a token will be valid until +# it is refreshed. Defaults to 21600. Can not be smaller than 30 and bigger than 21600. Has to be an integer. +# ec2_metadata_token_ttl_seconds=21600 + +# AzureSnitch +# Options are: +# Version of API to talk to. When not set, defaults to '2021-12-13'. +# azure_api_version=2021-12-13 + +# For all cloud-based snitches, there are following options available: +# +# Property to change metadata service url for a cloud-based snitch. The endpoint of a particular +# snitch will be appended to this property. A user is not normally using this property, it is here +# for tweaking the url of a service itself, e.g. for testing purposes. +# metadata_url=http://some/service +# +# Sets a specified timeout value, in duration format, to be used when opening a communications link to metadata service, +# referenced by an URLConnection. The timeout of zero (0s) is interpreted as an infinite timeout. +# Defaults to 30 seconds. +# metadata_request_timeout=30s \ No newline at end of file diff --git a/test/integration/setup/cassandra-multinode/3-node-1-dc/rackdc.node3.properties b/test/integration/setup/cassandra-multinode/3-node-1-dc/rackdc.node3.properties new file mode 100644 index 0000000000..8e51b76da9 --- /dev/null +++ b/test/integration/setup/cassandra-multinode/3-node-1-dc/rackdc.node3.properties @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# These properties are used with GossipingPropertyFileSnitch and will +# indicate the rack and dc for this node +dc=dc1 +rack=rack3 + +# Add a suffix to a datacenter name. Used by all cloud-based snitches. +#dc_suffix= + +# Uncomment the following line to make this snitch prefer the internal ip when possible, as the Ec2MultiRegionSnitch does. +# prefer_local=true + +# Datacenter and rack naming convention used by the Ec2Snitch and Ec2MultiRegionSnitch. +# Options are: +# legacy : datacenter name is the part of the availability zone name preceding the last "-" +# when the zone ends in -1 and includes the number if not -1. Rack is the portion of +# the availability zone name following the last "-". +# Examples: us-west-1a => dc: us-west, rack: 1a; us-west-2b => dc: us-west-2, rack: 2b; +# YOU MUST USE THIS VALUE IF YOU ARE UPGRADING A PRE-4.0 CLUSTER +# standard : Default value. datacenter name is the standard AWS region name, including the number. +# rack name is the region plus the availability zone letter. +# Examples: us-west-1a => dc: us-west-1, rack: us-west-1a; us-west-2b => dc: us-west-2, rack: us-west-2b; +# ec2_naming_scheme=standard +# +# Type of AWS IMDS, value might be either "v1" or "v2". Defaults to "v2". +# ec2_metadata_type=v2 +# If AWS IMDS of v2 is configured, ec2_metadata_token_ttl_seconds says how many seconds a token will be valid until +# it is refreshed. Defaults to 21600. Can not be smaller than 30 and bigger than 21600. Has to be an integer. +# ec2_metadata_token_ttl_seconds=21600 + +# AzureSnitch +# Options are: +# Version of API to talk to. When not set, defaults to '2021-12-13'. +# azure_api_version=2021-12-13 + +# For all cloud-based snitches, there are following options available: +# +# Property to change metadata service url for a cloud-based snitch. The endpoint of a particular +# snitch will be appended to this property. A user is not normally using this property, it is here +# for tweaking the url of a service itself, e.g. for testing purposes. +# metadata_url=http://some/service +# +# Sets a specified timeout value, in duration format, to be used when opening a communications link to metadata service, +# referenced by an URLConnection. The timeout of zero (0s) is interpreted as an infinite timeout. +# Defaults to 30 seconds. +# metadata_request_timeout=30s \ No newline at end of file diff --git a/test/integration/setup/cassandra-multinode/README.md b/test/integration/setup/cassandra-multinode/README.md new file mode 100644 index 0000000000..31a60162b5 --- /dev/null +++ b/test/integration/setup/cassandra-multinode/README.md @@ -0,0 +1,4 @@ +# Multi-node Datastore Cassandra Tests + +This suite tests the datastore on a Cassandra cluster with three nodes in a single datacenter. It is not perfectly +stable and is known to flake at times, likely due to incomplete truncation of the tables in the keyspace. \ No newline at end of file diff --git a/test/integration/setup/cassandra-multinode/docker-compose.3-node-1-dc.yaml b/test/integration/setup/cassandra-multinode/docker-compose.3-node-1-dc.yaml new file mode 100644 index 0000000000..aa050a296b --- /dev/null +++ b/test/integration/setup/cassandra-multinode/docker-compose.3-node-1-dc.yaml @@ -0,0 +1,99 @@ +networks: + spire-cassandra-network: + ipam: + config: + - subnet: 172.21.0.0/24 + +services: + cassandra-1: + container_name: cassandra-1 + image: cassandra:5 + command: + - "-Dcassandra.config=/cassandra.yaml" + ports: + - "9044:9044" + env_file: + - ./.env + environment: + - HEAP_NEWSIZE=1024M + - MAX_HEAP_SIZE=4096M + volumes: + - cassandra-node-1:/var/lib/cassandra:rw + - ./3-node-1-dc/node-1.yml:/cassandra.yaml:rw + - ./3-node-1-dc/rackdc.node1.properties:/etc/cassandra/cassandra-rackdc.properties:rw + restart: + on-failure + healthcheck: + test: ["CMD-SHELL", "nodetool status"] + interval: 2m + start_period: 2m + timeout: 10s + retries: 3 + networks: + spire-cassandra-network: + ipv4_address: 172.21.0.3 + cassandra-2: + container_name: cassandra-2 + image: cassandra:5 + command: + - "-Dcassandra.config=/cassandra.yaml" + ports: + - "9045:9045" + env_file: + - ./.env + environment: + - HEAP_NEWSIZE=1024M + - MAX_HEAP_SIZE=4096M + volumes: + - cassandra-node-2:/var/lib/cassandra:rw + - ./3-node-1-dc/node-2.yml:/cassandra.yaml:rw + - ./3-node-1-dc/rackdc.node2.properties:/etc/cassandra/cassandra-rackdc.properties:rw + restart: + on-failure + healthcheck: + test: ["CMD-SHELL", "nodetool status"] + interval: 2m + start_period: 2m + timeout: 10s + retries: 3 + depends_on: + cassandra-1: + condition: service_healthy + networks: + spire-cassandra-network: + ipv4_address: 172.21.0.4 + cassandra-3: + container_name: cassandra-3 + image: cassandra:5 + command: + - "-Dcassandra.config=/cassandra.yaml" + ports: + - "9046:9046" + env_file: + - ./.env + environment: + - HEAP_NEWSIZE=1024M + - MAX_HEAP_SIZE=4096M + volumes: + - cassandra-node-3:/var/lib/cassandra:rw + - ./3-node-1-dc/node-3.yml:/cassandra.yaml:rw + - ./3-node-1-dc/rackdc.node3.properties:/etc/cassandra/cassandra-rackdc.properties:rw + restart: + on-failure + healthcheck: + test: ["CMD-SHELL", "nodetool status"] + interval: 2m + start_period: 2m + timeout: 10s + retries: 3 + depends_on: + cassandra-2: + condition: service_healthy + networks: + spire-cassandra-network: + ipv4_address: 172.21.0.5 + +volumes: + cassandra-node-1: + cassandra-node-2: + cassandra-node-3: \ No newline at end of file diff --git a/test/integration/setup/cassandra-multinode/setup b/test/integration/setup/cassandra-multinode/setup new file mode 100755 index 0000000000..6370fc2903 --- /dev/null +++ b/test/integration/setup/cassandra-multinode/setup @@ -0,0 +1,48 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +docker-compose-up() { + if [ $# -eq 0 ]; then + log-debug "bringing up services..." + else + log-debug "bringing up $*..." + fi + docker compose -f "${DIR}/docker-compose.3-node-1-dc.yaml" up -d "$@" || fail-now "failed to bring up services." +} + +cassandra-up() { + SERVICE=$1 + + docker-compose-up "${SERVICE}" + + # Wait up to two minutes for each Cassandra node to be available. It should come up + # pretty quick on developer machines but CI/CD is slow. + MAXCHECKS=40 + CHECKINTERVAL=3 + READY= + for ((i=1;i<=MAXCHECKS;i++)); do + log-info "waiting for ${SERVICE} ($i of $MAXCHECKS max)..." + if docker compose -f "${DIR}/docker-compose.3-node-1-dc.yaml" exec -T "${SERVICE}" nodetool status >/dev/null; then + READY=1 + break + fi + sleep "${CHECKINTERVAL}" + done + + if [ -z ${READY} ]; then + fail-now "timed out waiting for ${SERVICE} to be ready" + fi +} + +test-cassandra() { + log-info "starting Cassandra cluster with one datacenter and 3 nodes..." + cassandra-up cassandra-1 + log-info "Cassandra node 1 is up, bringing up node 2..." + cassandra-up cassandra-2 + log-info "Cassandra node 2 is up, bringing up node 3..." + cassandra-up cassandra-3 + log-info "Cassandra node 3 is up, cluster is ready for testing" +} + +test-cassandra cassandra-5 || exit 1 diff --git a/test/integration/setup/cassandra-multinode/teardown b/test/integration/setup/cassandra-multinode/teardown new file mode 100755 index 0000000000..b853ac5898 --- /dev/null +++ b/test/integration/setup/cassandra-multinode/teardown @@ -0,0 +1,14 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +docker-down() { + if [ $# -eq 0 ]; then + log-debug "bringing down services..." + else + log-debug "bringing down $*..." + fi + docker compose -f "${DIR}/docker-compose.3-node-1-dc.yaml" down -v || fail-now "failed to bring down services." +} + +docker-down \ No newline at end of file diff --git a/test/integration/suites/datastore-mysql-pluggable/00-setup b/test/integration/suites/datastore-mysql-pluggable/00-setup new file mode 100755 index 0000000000..a20f853992 --- /dev/null +++ b/test/integration/suites/datastore-mysql-pluggable/00-setup @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +PKGDIR="${REPODIR}/pkg/server/datastore" + +log-debug "building mysql test harness..." +(cd "${PKGDIR}"; go test -c -o "${DIR}"/mysql.test -ldflags "-X github.com/spiffe/spire/pkg/server/datastore_test.TestDialect=mysql -X github.com/spiffe/spire/pkg/server/datastore_test.TestConnString=spire:test@tcp(localhost:9999)/spire?parseTime=true -X github.com/spiffe/spire/pkg/server/datastore_test.TestROConnString=spire:test@tcp(localhost:9999)/spire?parseTime=true") + +log-debug "copying over test data..." +cp -r "${PKGDIR}"/testdata . diff --git a/test/integration/suites/datastore-mysql-pluggable/01-test-variants b/test/integration/suites/datastore-mysql-pluggable/01-test-variants new file mode 100755 index 0000000000..681c50451f --- /dev/null +++ b/test/integration/suites/datastore-mysql-pluggable/01-test-variants @@ -0,0 +1,54 @@ +#!/bin/bash + +test-mysql() { + SERVICE=$1 + + docker-up "${SERVICE}" + + # The MySQL containers start up the MySQL instance to initialize the + # database. It is then brought down and started again. If we do a + # connectivity check during the initialization step, we might + # assume the database is ready to go prematurely. To prevent this, we + # will check for the log message indicating that initialization is complete. + INITMSG="MySQL init process done. Ready for start up." + MAXINITCHECKS=40 + INITCHECKINTERVAL=3 + INIT= + for ((i=1;i<=MAXINITCHECKS;i++)); do + log-info "waiting for ${SERVICE} database initialization ($i of $MAXINITCHECKS max)..." + if docker compose logs "${SERVICE}" | grep "$INITMSG"; then + INIT=1 + break + fi + sleep "${INITCHECKINTERVAL}" + done + + if [ -z ${INIT} ]; then + fail-now "timed out waiting for ${SERVICE} database to be initialized" + fi + + + # Wait up to two minutes for mysql to be available. It should come up + # pretty quick on developer machines but CI/CD is slow. + MAXREADYCHECKS=40 + READYCHECKINTERVAL=3 + READY= + for ((i=1;i<=MAXREADYCHECKS;i++)); do + log-info "waiting for ${SERVICE} to be ready ($i of $MAXREADYCHECKS max)..." + if docker compose exec -T "${SERVICE}" mysql -uspire -ptest -e "show databases;" > /dev/null; then + READY=1 + break + fi + sleep "${READYCHECKINTERVAL}" + done + + if [ -z ${READY} ]; then + fail-now "timed out waiting for ${SERVICE} to be ready" + fi + + log-info "running tests against ${SERVICE}..." + ./mysql.test || fail-now "tests failed" + docker-stop "${SERVICE}" +} + +test-mysql mysql-8-0 || exit 1 diff --git a/test/integration/suites/datastore-mysql-pluggable/README.md b/test/integration/suites/datastore-mysql-pluggable/README.md new file mode 100644 index 0000000000..8edb888d50 --- /dev/null +++ b/test/integration/suites/datastore-mysql-pluggable/README.md @@ -0,0 +1,10 @@ +# Datastore MySQL Suite + +## Description + +The suite runs the following MySQL versions against the SQL datastore unit tests: + +- 8.0 + +A special unit test binary is built from sources that targets the docker +containers running MySQL. diff --git a/test/integration/suites/datastore-mysql-pluggable/docker-compose.yaml b/test/integration/suites/datastore-mysql-pluggable/docker-compose.yaml new file mode 100644 index 0000000000..847acc25ac --- /dev/null +++ b/test/integration/suites/datastore-mysql-pluggable/docker-compose.yaml @@ -0,0 +1,12 @@ +services: + mysql-8-0: + image: mysql:8.0 + environment: + - MYSQL_PASSWORD=test + - MYSQL_DATABASE=spire + - MYSQL_USER=spire + - MYSQL_RANDOM_ROOT_PASSWORD=yes + tmpfs: + - /var/lib/mysql + ports: + - "9999:3306" diff --git a/test/integration/suites/datastore-mysql-pluggable/teardown b/test/integration/suites/datastore-mysql-pluggable/teardown new file mode 100755 index 0000000000..0bb84756dd --- /dev/null +++ b/test/integration/suites/datastore-mysql-pluggable/teardown @@ -0,0 +1 @@ +docker-down diff --git a/test/integration/suites/datastore-mysql-replication-pluggable/00-setup b/test/integration/suites/datastore-mysql-replication-pluggable/00-setup new file mode 100755 index 0000000000..32e95d8e96 --- /dev/null +++ b/test/integration/suites/datastore-mysql-replication-pluggable/00-setup @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +PKGDIR="${REPODIR}/pkg/server/datastore" + +log-debug "building mysql replication test harness..." +( +cd "${PKGDIR}" +go test -c -o "${DIR}"/mysql-replicated.test -ldflags "-X github.com/spiffe/spire/pkg/server/datastore_test.TestDialect=mysql -X github.com/spiffe/spire/pkg/server/datastore_test.TestConnString=spire:test@tcp(localhost:9999)/spire?parseTime=true -X github.com/spiffe/spire/pkg/server/datastore_test.TestROConnString=spire:test@tcp(localhost:10000)/spire?parseTime=true" +) + +log-debug "copying over test data..." +cp -r "${PKGDIR}"/testdata . diff --git a/test/integration/suites/datastore-mysql-replication-pluggable/01-test-variants b/test/integration/suites/datastore-mysql-replication-pluggable/01-test-variants new file mode 100755 index 0000000000..a09d2f7195 --- /dev/null +++ b/test/integration/suites/datastore-mysql-replication-pluggable/01-test-variants @@ -0,0 +1,121 @@ +#!/bin/bash + +replication_user='repl' +replication_user_pass='pass' +replication_channel='group_replication_recovery' + +wait-mysql-container-initialized() { + service=$1 + + # The MySQL containers start up the MySQL instance to initialize the + # database. It is then brought down and started again. If we do a + # connectivity check during the initialization step, we might + # assume the database is ready to go prematurely. To prevent this, we + # will check for the log message indicating that initialization is complete. + local init_msg="MySQL init process done. Ready for start up." + local max_init_checks=40 + local init_check_interval=3 + for ((i = 1; i <= max_init_checks; i++)); do + log-info "waiting for ${service} database initialization (${i} of ${max_init_checks} max)..." + if docker compose logs "${service}" | grep "${init_msg}"; then + return 1 + fi + sleep "${init_check_interval}" + done + + return 0 +} + +wait-mysql-container-ready() { + service=$1 + # Wait up to two minutes for mysql to be available. It should come up + # pretty quick on developer machines but CI/CD is slow. + local max_ready_checks=40 + local ready_check_interval=3 + for ((i = 1; i <= max_ready_checks; i++)); do + log-info "waiting for ${service} to be ready (${i} of ${max_ready_checks} max)..." + if docker compose exec -T "${service}" mysql -uspire -ptest -e "show databases;" >/dev/null; then + return 1 + break + fi + sleep "${ready_check_interval}" + done + + return 0 +} + +wait-mysql-container-initialized-and-ready() { + service=$1 + if wait-mysql-container-initialized "${service}"; then + fail-now "timed out waiting for ${service} database to be initialized" + fi + + if wait-mysql-container-ready "${service}"; then + fail-now "timed out waiting for ${service} to be ready" + fi +} + +get_mysql_root_password() { + container_name=$1 + root_password=$(docker logs "${container_name}" 2>/dev/null \ + | grep 'GENERATED ROOT PASSWORD' \ + | sed 's/^.*GENERATED ROOT PASSWORD: \([^[:space:]]\{1,\}\)[[:space:]]*$/\1/') + + if [ -z "${root_password}" ]; then + fail-now "Could not find root password for MySQL container ${container_name}. Container may not have initialized correctly." + fi + + echo "${root_password}" +} + +# Setup a primary server with group replication. +configure-readwrite-group-replication() { + service=$1 + mysql_root_password=$2 + + replication_script=" +SET @@GLOBAL.group_replication_bootstrap_group=1; +CREATE USER '${replication_user}'@'%'; +GRANT REPLICATION SLAVE ON *.* TO ${replication_user}@'%'; +FLUSH PRIVILEGES; +CHANGE MASTER TO MASTER_USER='${replication_user}' FOR CHANNEL '${replication_channel}'; +START GROUP_REPLICATION; +SET @@GLOBAL.group_replication_bootstrap_group=0; +SELECT * FROM performance_schema.replication_group_members; +" + docker compose exec -T "${service}" mysql -uroot "-p$mysql_root_password" -e "${replication_script}" +} + +# Setup a replica server with group replication. +configure-readonly-group-replication() { + service=$1 + mysql_root_password=$2 + + replication_script=" +CHANGE MASTER TO MASTER_USER='${replication_user}' FOR CHANNEL '${replication_channel}'; +START GROUP_REPLICATION; +" + docker compose exec -T "${service}" mysql -uroot "-p$mysql_root_password" -e "${replication_script}" +} + +test-mysql-replication() { + service_prefix=$1 + readwrite_service_name="${service_prefix}-readwrite" + readonly_service_name="${service_prefix}-readonly" + + docker-up "${readwrite_service_name}" "${readonly_service_name}" + wait-mysql-container-initialized-and-ready "${readwrite_service_name}" + wait-mysql-container-initialized-and-ready "${readonly_service_name}" + + readwrite_root_password=$(get_mysql_root_password "${readwrite_service_name}") + readonly_root_password=$(get_mysql_root_password "${readonly_service_name}") + + configure-readwrite-group-replication "${readwrite_service_name}" "${readwrite_root_password}" + configure-readonly-group-replication "${readonly_service_name}" "${readonly_root_password}" + + log-info "running tests against ${readwrite_service_name} and ${readonly_service_name}..." + ./mysql-replicated.test || fail-now "tests failed" + docker-stop "${readwrite_service_name}" "${readonly_service_name}" +} + +test-mysql-replication mysql-8-0 || exit 1 diff --git a/test/integration/suites/datastore-mysql-replication-pluggable/README.md b/test/integration/suites/datastore-mysql-replication-pluggable/README.md new file mode 100644 index 0000000000..f869d3a257 --- /dev/null +++ b/test/integration/suites/datastore-mysql-replication-pluggable/README.md @@ -0,0 +1,12 @@ +# Datastore MySQL replication Suite + +## Description + +Test that SPIRE Server is able to run a query in a readonly database that is replicated from a primary server, keeping it updated. +The suite runs the following MySQL versions against the SQL datastore unit tests: + +- 5.7 +- 8.0 + +A special unit test binary is built from source, targeting the docker +containers running MySQL. diff --git a/test/integration/suites/datastore-mysql-replication-pluggable/docker-compose.yaml b/test/integration/suites/datastore-mysql-replication-pluggable/docker-compose.yaml new file mode 100644 index 0000000000..b43ad6f15e --- /dev/null +++ b/test/integration/suites/datastore-mysql-replication-pluggable/docker-compose.yaml @@ -0,0 +1,61 @@ +services: + # MySQL 8.0 containers + mysql-8-0-readwrite: + image: mysql/mysql-server:8.0 + container_name: mysql-8-0-readwrite + environment: + - MYSQL_PASSWORD=test + - MYSQL_DATABASE=spire + - MYSQL_USER=spire + - MYSQL_RANDOM_ROOT_PASSWORD=yes + restart: unless-stopped + ports: + - "9999:3306" + command: + - "--server-id=1" + - "--log-bin=mysql-bin-1.log" + - "--enforce-gtid-consistency=ON" + - "--log-slave-updates=ON" + - "--gtid-mode=ON" + - "--transaction-write-set-extraction=XXHASH64" + - "--binlog-checksum=NONE" + - "--master-info-repository=TABLE" + - "--relay-log-info-repository=TABLE" + - "--plugin-load=group_replication.so" + - "--relay-log-recovery=ON" + - "--loose-group-replication-start-on-boot=OFF" + - "--loose-group-replication-group-name=43991639-43EE-454C-82BD-F08A13F3C3ED" + - "--loose-group-replication-local-address=mysql-8-0-readwrite:33061" + - "--loose-group-replication-group-seeds=mysql-8-0-readwrite:33061,mysql-8-0-readonly:33062" + - "--loose-group-replicaion-single-primary-mode=ON" + - "--loose-group-replication-enforce-update-everywhere-checks=OFF" + - "--loose-group-replication-auto-increment-increment=1" + mysql-8-0-readonly: + image: mysql/mysql-server:8.0 + environment: + - MYSQL_PASSWORD=test + - MYSQL_DATABASE=spire + - MYSQL_USER=spire + - MYSQL_RANDOM_ROOT_PASSWORD=yes + ports: + - "10000:3306" + container_name: mysql-8-0-readonly + command: + - "--server-id=2" + - "--log-bin=mysql-bin-1.log" + - "--enforce-gtid-consistency=ON" + - "--log-slave-updates=ON" + - "--gtid-mode=ON" + - "--transaction-write-set-extraction=XXHASH64" + - "--binlog-checksum=NONE" + - "--master-info-repository=TABLE" + - "--relay-log-info-repository=TABLE" + - "--plugin-load-add=group_replication.so" + - "--relay-log-recovery=ON" + - "--loose-group_replication_start_on_boot=OFF" + - "--loose-group_replication_group_name=43991639-43EE-454C-82BD-F08A13F3C3ED" + - "--loose-group-replication-local-address=mysql-8-0-readonly:33062" + - "--loose-group-replication-group-seeds=mysql-8-0-readwrite:33061,mysql-8-0-readonly:33062" + - "--loose-group-replication-single-primary-mode=ON" + - "--loose-group-replication-enforce-update-everywhere-checks=OFF" + - "--loose-group-replication-auto-increment-increment=1" diff --git a/test/integration/suites/datastore-mysql-replication-pluggable/teardown b/test/integration/suites/datastore-mysql-replication-pluggable/teardown new file mode 100755 index 0000000000..0bb84756dd --- /dev/null +++ b/test/integration/suites/datastore-mysql-replication-pluggable/teardown @@ -0,0 +1 @@ +docker-down diff --git a/test/integration/suites/datastore-postgres-pluggable/00-setup b/test/integration/suites/datastore-postgres-pluggable/00-setup new file mode 100755 index 0000000000..39bb275512 --- /dev/null +++ b/test/integration/suites/datastore-postgres-pluggable/00-setup @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +PKGDIR="${REPODIR}/pkg/server/datastore" + +log-debug "building postgres test harness..." +(cd "${PKGDIR}"; go test -c -o "${DIR}"/postgres.test -ldflags "-X github.com/spiffe/spire/pkg/server/datastore_test.TestDialect=postgres -X github.com/spiffe/spire/pkg/server/datastore_test.TestConnString=postgres://postgres:password@localhost:9999/postgres?sslmode=disable -X github.com/spiffe/spire/pkg/server/datastore_test.TestROConnString=postgres://postgres:password@localhost:9999/postgres?sslmode=disable") + +log-debug "copying over test data..." +cp -r "${PKGDIR}"/testdata . diff --git a/test/integration/suites/datastore-postgres-pluggable/01-test-variants b/test/integration/suites/datastore-postgres-pluggable/01-test-variants new file mode 100755 index 0000000000..cb8a3082b6 --- /dev/null +++ b/test/integration/suites/datastore-postgres-pluggable/01-test-variants @@ -0,0 +1,35 @@ +#!/bin/bash + +test-postgres() { + SERVICE=$1 + + docker-up "${SERVICE}" + + # Wait up to two minutes for postgres to be available. It should come up + # pretty quick on developer machines but CI/CD is slow. + MAXCHECKS=40 + CHECKINTERVAL=3 + READY= + for ((i=1;i<=MAXCHECKS;i++)); do + log-info "waiting for ${SERVICE} ($i of $MAXCHECKS max)..." + if docker compose exec -T "${SERVICE}" pg_isready -h localhost -U postgres >/dev/null; then + READY=1 + break + fi + sleep "${CHECKINTERVAL}" + done + + if [ -z ${READY} ]; then + fail-now "timed out waiting for ${SERVICE} to be ready" + fi + + log-info "running tests against ${SERVICE}..." + ./postgres.test || fail-now "tests failed" + docker-stop "${SERVICE}" +} + +test-postgres postgres-13 || exit 1 +test-postgres postgres-14 || exit 1 +test-postgres postgres-15 || exit 1 +test-postgres postgres-16 || exit 1 +test-postgres postgres-17 || exit 1 diff --git a/test/integration/suites/datastore-postgres-pluggable/README.md b/test/integration/suites/datastore-postgres-pluggable/README.md new file mode 100644 index 0000000000..f71c9caad5 --- /dev/null +++ b/test/integration/suites/datastore-postgres-pluggable/README.md @@ -0,0 +1,14 @@ +# Datastore PostgreSQL Suite + +## Description + +The suite runs the following PostgreSQL versions against the SQL datastore unit tests: + +- 13.x (latest) +- 14.x (latest) +- 15.x (latest) +- 16.x (latest) +- 17.x (latest) + +A special unit test binary is built from sources that targets the docker +containers running PostgreSQL. diff --git a/test/integration/suites/datastore-postgres-pluggable/docker-compose.yaml b/test/integration/suites/datastore-postgres-pluggable/docker-compose.yaml new file mode 100644 index 0000000000..4d8001ade1 --- /dev/null +++ b/test/integration/suites/datastore-postgres-pluggable/docker-compose.yaml @@ -0,0 +1,46 @@ +services: + postgres-13: + image: postgres:13 + command: -c fsync=off + environment: + - POSTGRES_PASSWORD=password + tmpfs: + - /var/lib/postgresql + ports: + - "9999:5432" + postgres-14: + image: postgres:14 + command: -c fsync=off + environment: + - POSTGRES_PASSWORD=password + tmpfs: + - /var/lib/postgresql + ports: + - "9999:5432" + postgres-15: + image: postgres:15 + command: -c fsync=off + environment: + - POSTGRES_PASSWORD=password + tmpfs: + - /var/lib/postgresql + ports: + - "9999:5432" + postgres-16: + image: postgres:16 + command: -c fsync=off + environment: + - POSTGRES_PASSWORD=password + tmpfs: + - /var/lib/postgresql + ports: + - "9999:5432" + postgres-17: + image: postgres:17 + command: -c fsync=off + environment: + - POSTGRES_PASSWORD=password + tmpfs: + - /var/lib/postgresql + ports: + - "9999:5432" diff --git a/test/integration/suites/datastore-postgres-pluggable/teardown b/test/integration/suites/datastore-postgres-pluggable/teardown new file mode 100755 index 0000000000..0bb84756dd --- /dev/null +++ b/test/integration/suites/datastore-postgres-pluggable/teardown @@ -0,0 +1 @@ +docker-down diff --git a/test/integration/suites/datastore-postgres-replication-pluggable/00-setup b/test/integration/suites/datastore-postgres-replication-pluggable/00-setup new file mode 100755 index 0000000000..2470944f1a --- /dev/null +++ b/test/integration/suites/datastore-postgres-replication-pluggable/00-setup @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +PKGDIR="${REPODIR}/pkg/server/datastore" + +log-debug "building postgres replication test harness..." +(cd "${PKGDIR}"; go test -c -o "${DIR}"/postgres.replication.test -ldflags "-X github.com/spiffe/spire/pkg/server/datastore_test.TestDialect=postgres -X github.com/spiffe/spire/pkg/server/datastore_test.TestConnString=postgres://postgres:password@localhost:9999/postgres?sslmode=disable -X github.com/spiffe/spire/pkg/server/datastore_test.TestROConnString=postgres://postgres:password@localhost:10000/postgres?sslmode=disable") + +log-debug "copying over test data..." +cp -r "${PKGDIR}"/testdata . diff --git a/test/integration/suites/datastore-postgres-replication-pluggable/01-test-variants b/test/integration/suites/datastore-postgres-replication-pluggable/01-test-variants new file mode 100755 index 0000000000..5078beb645 --- /dev/null +++ b/test/integration/suites/datastore-postgres-replication-pluggable/01-test-variants @@ -0,0 +1,42 @@ +#!/bin/bash + +wait-container-ready() { + service=$1 + + # Wait up to two minutes for postgres to be available. It should come up + # pretty quick on developer machines but CI/CD is slow. + local max_checks=40 + local check_interval=3 + local ready= + for ((i=1;i<=max_checks;i++)); do + log-info "waiting for ${service} ($i of $max_checks max)..." + if docker compose exec -T "${service}" pg_isready -h localhost -U postgres >/dev/null; then + return 1 + fi + sleep "${check_interval}" + done + + fail-now "timed out waiting for ${service} to be ready" + return 0 +} + + +test-postgres() { + service_prefix=$1 + readwrite_service_name="${service_prefix}-readwrite" + readonly_service_name="${service_prefix}-readonly" + + docker-up "${readwrite_service_name}" "${readonly_service_name}" + wait-container-ready "${readwrite_service_name}" + wait-container-ready "${readonly_service_name}" + + log-info "running tests against ${SERVICE}..." + ./postgres.replication.test || fail-now "tests failed" + docker-stop "${readwrite_service_name}" "${readonly_service_name}" +} + +test-postgres postgres-13 || exit 1 +test-postgres postgres-14 || exit 1 +test-postgres postgres-15 || exit 1 +test-postgres postgres-16 || exit 1 +test-postgres postgres-17 || exit 1 diff --git a/test/integration/suites/datastore-postgres-replication-pluggable/README.md b/test/integration/suites/datastore-postgres-replication-pluggable/README.md new file mode 100644 index 0000000000..9c8d00a7f0 --- /dev/null +++ b/test/integration/suites/datastore-postgres-replication-pluggable/README.md @@ -0,0 +1,15 @@ +# Datastore PostgreSQL Suite + +## Description + +Test that SPIRE Server is able to run a query in a readonly database that is replicated from a primary server, keeping it updated. +The suite runs the following PostgreSQL versions against the SQL datastore unit tests: + +- 13.x (latest) +- 14.x (latest) +- 15.x (latest) +- 16.x (latest) +- 17.x (latest) + +A special unit test binary is built from sources that targets the docker +containers running PostgreSQL. diff --git a/test/integration/suites/datastore-postgres-replication-pluggable/docker-compose.yaml b/test/integration/suites/datastore-postgres-replication-pluggable/docker-compose.yaml new file mode 100644 index 0000000000..02a45d1955 --- /dev/null +++ b/test/integration/suites/datastore-postgres-replication-pluggable/docker-compose.yaml @@ -0,0 +1,141 @@ +services: + postgres-13-readwrite: + image: postgres:13 + command: -c fsync=off + environment: + - POSTGRES_PASSWORD=password + - POSTGRES_USER=postgres + - POSTGRES_DB=spire + - PG_REP_USER=rep + - PG_REP_PASSWORD=pass + volumes: + - ./principal/init.sh:/docker-entrypoint-initdb.d/init.sh + ports: + - "9999:5432" + postgres-13-readonly: + image: postgres:13 + command: -c fsync=off + user: postgres + environment: + - POSTGRES_PASSWORD=password + - POSTGRES_USER=postgres + - PG_REP_USER=rep + - PG_REP_PASSWORD=pass + - PRINCIPAL_NAME=postgres-13-readwrite + entrypoint: ["/docker-entrypoint.sh", "postgres"] + volumes: + - ./replica/docker-entrypoint.sh:/docker-entrypoint.sh + ports: + - "10000:5432" + postgres-14-readwrite: + image: postgres:14 + command: -c fsync=off + environment: + - POSTGRES_PASSWORD=password + - POSTGRES_USER=postgres + - POSTGRES_DB=spire + - PG_REP_USER=rep + - PG_REP_PASSWORD=pass + volumes: + - ./principal/init.sh:/docker-entrypoint-initdb.d/init.sh + ports: + - "9999:5432" + postgres-14-readonly: + image: postgres:14 + command: -c fsync=off + user: postgres + environment: + - POSTGRES_PASSWORD=password + - POSTGRES_USER=postgres + - PG_REP_USER=rep + - PG_REP_PASSWORD=pass + - PRINCIPAL_NAME=postgres-14-readwrite + entrypoint: ["/docker-entrypoint.sh", "postgres"] + volumes: + - ./replica/docker-entrypoint.sh:/docker-entrypoint.sh + ports: + - "10000:5432" + postgres-15-readwrite: + image: postgres:15 + command: -c fsync=off + environment: + - POSTGRES_PASSWORD=password + - POSTGRES_USER=postgres + - POSTGRES_DB=spire + - PG_REP_USER=rep + - PG_REP_PASSWORD=pass + volumes: + - ./principal/init.sh:/docker-entrypoint-initdb.d/init.sh + ports: + - "9999:5432" + postgres-15-readonly: + image: postgres:15 + command: -c fsync=off + user: postgres + environment: + - POSTGRES_PASSWORD=password + - POSTGRES_USER=postgres + - PG_REP_USER=rep + - PG_REP_PASSWORD=pass + - PRINCIPAL_NAME=postgres-15-readwrite + entrypoint: ["/docker-entrypoint.sh", "postgres"] + volumes: + - ./replica/docker-entrypoint.sh:/docker-entrypoint.sh + ports: + - "10000:5432" + postgres-16-readwrite: + image: postgres:16 + command: -c fsync=off + environment: + - POSTGRES_PASSWORD=password + - POSTGRES_USER=postgres + - POSTGRES_DB=spire + - PG_REP_USER=rep + - PG_REP_PASSWORD=pass + volumes: + - ./principal/init.sh:/docker-entrypoint-initdb.d/init.sh + ports: + - "9999:5432" + postgres-16-readonly: + image: postgres:16 + command: -c fsync=off + user: postgres + environment: + - POSTGRES_PASSWORD=password + - POSTGRES_USER=postgres + - PG_REP_USER=rep + - PG_REP_PASSWORD=pass + - PRINCIPAL_NAME=postgres-16-readwrite + entrypoint: ["/docker-entrypoint.sh", "postgres"] + volumes: + - ./replica/docker-entrypoint.sh:/docker-entrypoint.sh + ports: + - "10000:5432" + postgres-17-readwrite: + image: postgres:17 + command: -c fsync=off + environment: + - POSTGRES_PASSWORD=password + - POSTGRES_USER=postgres + - POSTGRES_DB=spire + - PG_REP_USER=rep + - PG_REP_PASSWORD=pass + volumes: + - ./principal/init.sh:/docker-entrypoint-initdb.d/init.sh + ports: + - "9999:5432" + postgres-17-readonly: + image: postgres:17 + command: -c fsync=off + user: postgres + environment: + - POSTGRES_PASSWORD=password + - POSTGRES_USER=postgres + - PG_REP_USER=rep + - PG_REP_PASSWORD=pass + - PRINCIPAL_NAME=postgres-17-readwrite + entrypoint: ["/docker-entrypoint.sh", "postgres"] + volumes: + - ./replica/docker-entrypoint.sh:/docker-entrypoint.sh + ports: + - "10000:5432" diff --git a/test/integration/suites/datastore-postgres-replication-pluggable/principal/init.sh b/test/integration/suites/datastore-postgres-replication-pluggable/principal/init.sh new file mode 100755 index 0000000000..560c4b5769 --- /dev/null +++ b/test/integration/suites/datastore-postgres-replication-pluggable/principal/init.sh @@ -0,0 +1,14 @@ +#!/bin/bash +echo "host replication all 0.0.0.0/0 md5" >>"$PGDATA/pg_hba.conf" +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL +CREATE USER $PG_REP_USER WITH REPLICATION ENCRYPTED PASSWORD '$PG_REP_PASSWORD'; +EOSQL + +cat >>${PGDATA}/postgresql.conf < ~/.pgpass +chmod 0600 ~/.pgpass + +cat ~/.pgpass + +until (echo >/dev/tcp/${PRINCIPAL_NAME}/5432) &>/dev/null +do +echo "Waiting for principal to start..." +sleep 1s +done + +until pg_basebackup -h ${PRINCIPAL_NAME} -D ${PGDATA} -U ${PG_REP_USER} -Fp -Xs -P -R +do +echo "Waiting for principal to connect..." +sleep 1s +done + +echo "host replication all 0.0.0.0/0 md5" >> "$PGDATA/pg_hba.conf" + +cat >> ${PGDATA}/postgresql.conf </dev/null || true # add environment file for overrides + +################################################# +# Execute the test suite +################################################# +run-step() { + local script="$1" + if [ ! -x "$script" ]; then + fail-now "Failing: \"$script\" is not executable" + fi + log-debug "executing $(basename "$script")..." + + # Execute the step in a separate bash process that ensures that strict + # error handling is enabled (e.g. `errexit` and `pipefail`) and sources the + # common script. A subshell CANNOT be used as an alternative due to the way + # bash handles `errexit` from subshells (i.e. ignores it). + bash -s </dev/null || true ################################################# # Execute the test suite diff --git a/test/plugintest/load.go b/test/plugintest/load.go index 942ab32a75..4eff7bc1ad 100644 --- a/test/plugintest/load.go +++ b/test/plugintest/load.go @@ -3,6 +3,7 @@ package plugintest import ( "context" "io" + "strings" "testing" "github.com/sirupsen/logrus" @@ -25,7 +26,8 @@ type Plugin interface { func Load(t *testing.T, builtIn catalog.BuiltIn, pluginFacade catalog.Facade, options ...Option) Plugin { conf := &config{ builtInConfig: catalog.BuiltInConfig{ - Log: testLogger(t), + Log: testLogger(t), + MaxGrpcMessageSize: catalog.DefaultMaxGrpcMessageSize, }, } for _, opt := range options { @@ -40,7 +42,13 @@ func Load(t *testing.T, builtIn catalog.BuiltIn, pluginFacade catalog.Facade, op } } require.NoError(t, err) - t.Cleanup(func() { assert.NoError(t, conn.Close()) }) + t.Cleanup(func() { + err := conn.Close() + if err != nil && strings.Contains(err.Error(), "the client connection is closing") { + return + } + assert.NoError(t, err) + }) var facades []catalog.Facade if pluginFacade != nil { diff --git a/test/plugintest/option.go b/test/plugintest/option.go index f26b72ade2..18cd67cbcc 100644 --- a/test/plugintest/option.go +++ b/test/plugintest/option.go @@ -49,6 +49,13 @@ func CoreConfig(coreConfig catalog.CoreConfig) Option { }) } +// MaxGrpcMessageSize sets the maximum gRPC message size for the plugin connection. +func MaxGrpcMessageSize(size int) Option { + return optionFunc(func(conf *config) { + conf.builtInConfig.MaxGrpcMessageSize = size + }) +} + // Configure provides raw configuration to the plugin for configuration. func Configure(plainConfig string) Option { return optionFunc(func(conf *config) { diff --git a/test/spiretest/assertions.go b/test/spiretest/assertions.go index d3396ace0d..58cdc9f176 100644 --- a/test/spiretest/assertions.go +++ b/test/spiretest/assertions.go @@ -134,6 +134,57 @@ func AssertProtoListEqual(tb testing.TB, expected, actual any) bool { return true } +func RequireProtoListsSameEls(tb testing.TB, expected, actual any) { + tb.Helper() + if !AssertProtoListsSameEls(tb, expected, actual) { + tb.FailNow() + } +} + +func AssertProtoListsSameEls(tb testing.TB, expected, actual any) bool { + tb.Helper() + ev := reflect.ValueOf(expected) + et := ev.Type() + av := reflect.ValueOf(actual) + at := av.Type() + + if et.Kind() != reflect.Slice { + return assert.Fail(tb, "expected value is not a slice") + } + if !et.Elem().Implements(protoMessageType) { + return assert.Fail(tb, "expected value is not a slice of elements that implement proto.Message") + } + + if at.Kind() != reflect.Slice { + return assert.Fail(tb, "actual value is not a slice") + } + if !at.Elem().Implements(protoMessageType) { + return assert.Fail(tb, "actual value is not a slice of elements that implement proto.Message") + } + + if !assert.Equal(tb, ev.Len(), av.Len(), "expected %d elements in list; got %d", ev.Len(), av.Len()) { + return false + } + + for i := range ev.Len() { + e := ev.Index(i).Interface().(proto.Message) + + found := false + for j := range av.Len() { + a := av.Index(j).Interface().(proto.Message) + if CheckProtoEqual(tb, e, a) { + found = true + break + } + } + if !assert.True(tb, found, "expected proto %d in expected list not found in actual list", i) { + return false + } + } + + return true +} + func CheckProtoListEqual(tb testing.TB, expected, actual any) bool { tb.Helper() ev := reflect.ValueOf(expected)