diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ccc3af..2856048 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,23 +12,23 @@ jobs: env: MIX_ENV: test - # YARD run eventstore/erlang versions in a matrix? - services: - eventstore: - # image: eventstore/eventstore:21.2.0-bionic - image: docker.pkg.github.com/eventstore/eventstore/eventstore:ci - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - env: - EVENTSTORE_INSECURE: "true" - ports: - - 2113:2113 - steps: - name: Checkout uses: actions/checkout@v2 + - name: install EventStoreDB + run: | + curl -s https://packagecloud.io/install/repositories/EventStore/EventStore-OSS/script.deb.sh | sudo bash + sudo apt update + sudo apt install eventstore-oss + sudo mkdir -p /etc/eventstore/certs + sudo cp -r ./certs/ca /etc/eventstore/certs/ + sudo cp ./certs/node1/* /etc/eventstore/certs/ + sudo cp ./certs/eventstore.conf /etc/eventstore/ + sudo chown -R eventstore /etc/eventstore + sudo chgrp -R eventstore /etc/eventstore + sudo systemctl restart eventstore + - name: Determine the elixir version run: echo "ELIXIR_VERSION=$(grep -h elixir .tool-versions | awk '{ print $2 }')" >> $GITHUB_ENV diff --git a/.iex.exs b/.iex.exs index 6580b00..603b5f9 100644 --- a/.iex.exs +++ b/.iex.exs @@ -1,5 +1,15 @@ make_server = fn -> - {:ok, pid} = Spear.Connection.start_link(connection_string: "esdb://localhost:2113") + params = [ + connection_string: "esdb://localhost:2113?tls=true", + credentials: {"admin", "changeit"}, + opts: [ + transport_opts: [ + cacertfile: Path.join([__DIR__, "certs", "ca", "ca.crt"]) + ] + ] + ] + + {:ok, pid} = Spear.Connection.start_link(params) pid end diff --git a/CHANGELOG.md b/CHANGELOG.md index ad42634..f258313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,20 @@ Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to --> +## 0.1.3 - 2021-04-15 + +### Added + +- Added documentation and functionality for using TLS certificates + - see `Spear.Connection` and the [security guide](guides/security.md) +- Added documentation and functionality for setting the global stream ACL + - see `Spear.set_global_acl/4` and the `Spear.Acl` module +- Added functionality for getting and setting stream-level metadata. + - `Spear.meta_stream/1` + - `Spear.get_stream_metadata/3` + - `Spear.set_stream_metadata/3` + - `Spear.StreamMetadata` + ## 0.1.2 - 2021-04-14 ### Added diff --git a/certs/ca/ca.crt b/certs/ca/ca.crt new file mode 100644 index 0000000..594057b --- /dev/null +++ b/certs/ca/ca.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDzTCCArWgAwIBAgIRALwNR581upPaABvXc+DsPmwwDQYJKoZIhvcNAQELBQAw +YjELMAkGA1UEBhMCVUsxGDAWBgNVBAoTD0V2ZW50IFN0b3JlIEx0ZDE5MDcGA1UE +AxMwRXZlbnRTdG9yZURCIENBIGJjMGQ0NzlmMzViYTkzZGEwMDFiZDc3M2UwZWMz +ZTZjMB4XDTIxMDQxNDIyMDYxN1oXDTI2MDQxNDIyMDYxN1owYjELMAkGA1UEBhMC +VUsxGDAWBgNVBAoTD0V2ZW50IFN0b3JlIEx0ZDE5MDcGA1UEAxMwRXZlbnRTdG9y +ZURCIENBIGJjMGQ0NzlmMzViYTkzZGEwMDFiZDc3M2UwZWMzZTZjMIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzwwIW1IgG5kvn+claQAGbKL46a+rzV7r ++6WEAUltcLxo3Yq0oFuoc3qH1qmHrjVVdbpVuSyZHg2TDAVX3X6vE5jhRLmd9tVE +VEORZjKB/GlZtYO6DgwCaK1k4AJYrad2Tk61W6aLhgp3IkozmVLanvx0cULjFons +81sWl7TxP1Ig6nke3lKoiJT9igZS3KO5//xzuCg5oK7ix+MOBECmnQU0FeYjDIec +Pf8Eet3AiAna7LTB4e39ADE5NC04oD3ZEjwuRi0M+nDXD2d+c9NiUGt7HXexpGiw +IwRHFbfHMmuEyep4i7doo7JCLBtDfmkk1qN72A7+LcIOWh7tWy8UKwIDAQABo34w +fDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATApBgNVHQ4EIgQg +ha9nFdT5TBLAirXPTkF5uec+CeCPJ13x7/as/OeEB5QwKwYDVR0jBCQwIoAgha9n +FdT5TBLAirXPTkF5uec+CeCPJ13x7/as/OeEB5QwDQYJKoZIhvcNAQELBQADggEB +AB7eSquv3nZJiktJI5pZQVdIj5YoJpOlsIAWizBo3xqHioW5gxhR01G7qBQRjoiZ +n+xQJDs+cfmsXJunUek+kLr1k1Io3EDffGyIau3Qtig5iPVOyXSLgmYOM4npMQXR +LMPGzdJRM0rtqioQCm2XT4cC98FqNjCZOx2fC2CMvuug7p73FA05f/Mo2jEfgy/L +iBz5k3IExbWk24GN5Dp1q5VU2PLK2/ZcobcPTU2SKDOs/dr1gp3LfUk4dhTsLpQI +eYKbvKHftLDYBJsXwU0vb5BtVL9E0yUV89edSiY6v0+Ax1iNLcYOOQ2tLz3K6zKc +dWBiKrrcX1fLA8RXv42No+Q= +-----END CERTIFICATE----- diff --git a/certs/ca/ca.key b/certs/ca/ca.key new file mode 100644 index 0000000..6a6ef9e --- /dev/null +++ b/certs/ca/ca.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAzwwIW1IgG5kvn+claQAGbKL46a+rzV7r+6WEAUltcLxo3Yq0 +oFuoc3qH1qmHrjVVdbpVuSyZHg2TDAVX3X6vE5jhRLmd9tVEVEORZjKB/GlZtYO6 +DgwCaK1k4AJYrad2Tk61W6aLhgp3IkozmVLanvx0cULjFons81sWl7TxP1Ig6nke +3lKoiJT9igZS3KO5//xzuCg5oK7ix+MOBECmnQU0FeYjDIecPf8Eet3AiAna7LTB +4e39ADE5NC04oD3ZEjwuRi0M+nDXD2d+c9NiUGt7HXexpGiwIwRHFbfHMmuEyep4 +i7doo7JCLBtDfmkk1qN72A7+LcIOWh7tWy8UKwIDAQABAoIBABypHb8Gb0tiuST5 +akROrJT9Olee6blUGnaLQuqqr2ubqSiBut830OmrXIJqlU2YNGxHjvZDJi7y0hgn +5THUB4g+8XACAcvZWcwQTmBHPZcjPjfSND8dinfTCNO5f20KcWYFnzVAqK+1Yyhr +/RiMT5cSe0vyZl0IWrSVN1towLxy8iyeDmlCnmiLExMvAFsPgEZyI2/qctxTtnkS +qy3RWsky35hUctBumY1ZOPUDmRo0S8Qvca5oRi4hQ905uDR/P+NX9ceqNYgxN6YA +3A4bq/ETL+h6Xx0gn7w5UWWWknP2oToFU7HGZZjYw0zxWvdgHiAJHDybJ4AZOzAM +RNMkcAECgYEA8S17gGmgWm4quozAXPZcgSTbmvQMD52jEL8eV6Ek5ts8lmcKLETz +8S8O29Q46jDRRbY20WNUq88e8WGKv2/3YCy3ySG3jLO3am5Db40Mx8IquTPRUYPx +LwPPfmx8/W5Xb3cnBnGl6gj9XYOa2WvfNUWZ/tJHqJvdqsiwllckLoECgYEA28WQ +C4Mdi6TB+eVJBWsF4udALDxEuH6SScWwRmbRdd1kIo8j463NLafrA62Der+XOqx3 +54Zx6ENfbj4o3exXDdFuA6ESs63tWeoLKuxtX/PdZmF1Zjx3l8kqd2wNaUygWmJh +iO2JYqH6FM6/ZqphmN/YYVHfmW3AZusRpSRKBKsCgYEAr64852aJ2zWixG8g9Na0 +vZIWsgISAxIGJX3CYXzNv6h1su1t+J9lvwtTXIhzyQw0dP5tYgtkMx7V4Gj4Q8kQ +vqr0WXvJE6IZ+lpFny104NIsguofEKz29BNngyUNyyIkaNq3v7brb9aKkSL7mmM8 +nbaMnZWZg1W+m9hC4dCqV4ECgYEApc/rHCRymDdYeth5PXM/37AmBLn8B07HxI04 +sAVHJ6w/rqtcop0w3q+AayfwuR3wVb5mQPJ44opiZ+TSJI36KFzIqkhOue4R0/L3 +Ng1ngCuX8XS6hMY+XPDT74JApB/CJC9x80N0kkwvSJ+sXSNTu2m38cU59KKPtZbJ +m1VD2z0CgYEAt3T1Ztdmu4a+SXZuqH3+NAxR5/0pyG6I6JgQRxY4QQQ3SYFfai1i +QDi012A9WA/98J492Ro6ctk6GLCVZrvN39Ez3BB/Uw12/3wZcdOLCB8B7wRfPr5Q +RfRSERKqCCIKpjXbXBbwDa5gKSYO5NxAD8WXyFwsqGbTPBQUaWyNAnE= +-----END RSA PRIVATE KEY----- diff --git a/certs/eventstore.conf b/certs/eventstore.conf new file mode 100644 index 0000000..32dc72a --- /dev/null +++ b/certs/eventstore.conf @@ -0,0 +1,21 @@ +--- +# Paths +Db: /var/lib/eventstore +Index: /var/lib/eventstore/index +Log: /var/log/eventstore + +# Certificates configuration +CertificateFile: /etc/eventstore/certs/node.crt +CertificatePrivateKeyFile: /etc/eventstore/certs/node.key +TrustedRootCertificatesPath: /etc/eventstore/certs/ca + +# Network configuration +IntIp: 0.0.0.0 +ExtIp: 0.0.0.0 +HttpPort: 2113 +IntTcpPort: 1112 +EnableExternalTcp: false +EnableAtomPubOverHTTP: true + +# Projections configuration +RunProjections: All diff --git a/certs/node1/node.crt b/certs/node1/node.crt new file mode 100644 index 0000000..d5b0422 --- /dev/null +++ b/certs/node1/node.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDuDCCAqCgAwIBAgIRANp2x4vHsAavXVZRkKzWCpUwDQYJKoZIhvcNAQELBQAw +YjELMAkGA1UEBhMCVUsxGDAWBgNVBAoTD0V2ZW50IFN0b3JlIEx0ZDE5MDcGA1UE +AxMwRXZlbnRTdG9yZURCIENBIGJjMGQ0NzlmMzViYTkzZGEwMDFiZDc3M2UwZWMz +ZTZjMB4XDTIxMDQxNDIzMTIxMloXDTIyMDQxNDIzMTIxMlowHDEaMBgGA1UEAxMR +ZXZlbnRzdG9yZWRiLW5vZGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQCoEM6YNq5p2invjpumUEwZfmeP8nR0kzhf9lu1mYHD7Qls4wtH6GSsDnpzHubg +eSXEnovTQmKHEjPVtX7lxFfl3w3sQM0BouuLheJajLRUbfkV8l+Bbp1VbKgr+mZ9 +Ryeer8H+cbEyVPDRCqHvBdI7I16lzYFsgw+IKb1RpLLt1sCiA68b76JMWjs9l0iB +PslBYMt7ZJ1gkiX6cXcuk9IbX4xUCrvAiL4v50Wa3aCKR+UWNepE1G6lRmneGBdk +DnX9b+LMhqRWUimcYjQJsh+HS7WB0c8aRa4XlC4DYFfYspPeg7dOJ0xGv0mcDsbA +BD6t0bSG/Qg3cZu0FkezK1bVAgMBAAGjga4wgaswDgYDVR0PAQH/BAQDAgWgMB0G +A1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMCkGA1Ud +DgQiBCDEYEW376ALE6W8Wdt3WNsXJRYjeKyPu8Ofx2QWe3otsjArBgNVHSMEJDAi +gCCFr2cV1PlMEsCKtc9OQXm55z4J4I8nXfHv9qz854QHlDAUBgNVHREEDTALggls +b2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggEBAJySXczvtf+f3IF0GxmjEVMJTvNa +TSKaHt6yDaSnUBfa3vF+G+ASIVj1pmJV9/4bCSioV1GaqCpdcYa2fIstGKsy3hyR +KtR08z1JR8+dBlu3Ob4hjQDXpuDkAK4DF+aNqEopOr1GfLyvfZ1k7Oo3qLrqUXQ3 +OKx47BUYaoerb/hfKJY0C2IyCbGlb+wrQvpyKIABM2EdpXTKmWUYhrNW8kYMN/x/ +83A3oTzCEduSDarTE6MTXFBR9StE3Ywu8BudJO8BNraNUEGba3/Fx8EKOojz3CP6 +rLYOI6WA0hbATxQw7Pa2XjbJ5nEYnJWl9XQ3B84AxR5zq6SifAh3Q2bvsso= +-----END CERTIFICATE----- diff --git a/certs/node1/node.key b/certs/node1/node.key new file mode 100644 index 0000000..9016c50 --- /dev/null +++ b/certs/node1/node.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAqBDOmDauadop746bplBMGX5nj/J0dJM4X/ZbtZmBw+0JbOML +R+hkrA56cx7m4HklxJ6L00JihxIz1bV+5cRX5d8N7EDNAaLri4XiWoy0VG35FfJf +gW6dVWyoK/pmfUcnnq/B/nGxMlTw0Qqh7wXSOyNepc2BbIMPiCm9UaSy7dbAogOv +G++iTFo7PZdIgT7JQWDLe2SdYJIl+nF3LpPSG1+MVAq7wIi+L+dFmt2gikflFjXq +RNRupUZp3hgXZA51/W/izIakVlIpnGI0CbIfh0u1gdHPGkWuF5QuA2BX2LKT3oO3 +TidMRr9JnA7GwAQ+rdG0hv0IN3GbtBZHsytW1QIDAQABAoIBAB+Jl9sEV9JROBFW +B2s8IiuehryCWMwPXELVrfvz5F/puR0Ptew2db27scqsf9KbqTSuM7re+DI0fjma +J0figkQGiUxOFKo78ktqQkGPqb82K8msg7N8GFYRX7Vw9Y6Irayfep3Oo9u4CMCR +aDW8N+kVCAvA9opwRZfdjUMmztTGa68Mc0pYbmqcPEsgojrYtLmdGnvvORHKWGAU +IjW957LbrjmGGAuQbfcA7LJuP309gCzaxU1nimtKTIrG4LybrIqmWvfqLfSDFwIr +XMZG+CBhNPdb4X2bbx74hTWghsj/liZL9zezgRwa8nnNqMeFoSv4F6qVR7iLwFKE +37QNBbkCgYEA3uHhkkht9I8Ad4V6JbbgHLFVHa+pSc1m7y4taOt1ltscdvUWJkQF +GCJRRxHFXSXKHLu5iSS2GAUmMSxjulFSzsCXYJ8YFyCXDCGPQOzfFVgbLxBlCNKd +Gu3cc9aFLz7jyA4DVHZUJ37yOWO+DFpRkB4N7c9en5/x9vSt/PF9iwsCgYEAwQnH +Y0CXGovLR0Ab29FC6fbh1f3SdBGpUPnigQvedi/pzNz4oZvP7gGeaQutWhfTJFrB +8cu2F/NrCgBcZ1NWq3o8RMNz13fnwWdKX91sSBsYRFGEPNxZ/6ZKWqqiXObHcePb +vRF4nftMsofUxnI4jvh8jNVofN7eQb1CXCPK0Z8CgYABdl3yhcMi7aVFI30Prkl+ +JrO2RCbKMyzPuO/XVmQpHzrqlOUWTy/xXphF7RnsaIkQ8zJecf033yDHBdGJsWrn +rF/R5HlV/YLAM6Aq/uLf0voqruLa0fbx7EmcAPZSvwjjkSP4c+ZNdAnG0p62mgka +9veEbe3jAjumMSjLFhKKzQKBgQCaWKhNSsrG1fnOWYss4pAfJGCESrPoTGrWLUcX +KZdRZpQJUrGV/lBuHGs90LFl9ODFE7A5FkndsqrmT02S7EbDSzQ/Qwwvv1bWBDGq +nw/CQ6/OiGM0ineHer2+6upxX7Ee9jKvZPXNU66KnSLbHV7tqe9kaApotYZ+h8Y0 +iAXWPwKBgQDAdRdAIpasJDBjFqTuc3XkTlYk0/WnjSbxFrNdtVQsCM4k3Ak28r7e +8BlHg60oxK5hZvYFFZ9WdotfB4VihjBOj88t2T6+Gw7WF4XqAzkku93r5lWctCRr +BjKp+H1MNDGzItbinmO98qSxbUctoqNjjRH6N3bE2DWgFg1ONM0Rqg== +-----END RSA PRIVATE KEY----- diff --git a/config/test.exs b/config/test.exs index 8e4f8c4..3c803fe 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,3 +1,12 @@ import Config config :spear, Spear.Test.ClientFixture, connection_string: "esdb://localhost:2113" + +config :spear, :config, + connection_string: "esdb://localhost:2113?tls=true", + opts: [ + transport_opts: [ + cacertfile: Path.join([__DIR__ | ~w(.. certs ca ca.crt)]) + ] + ], + credentials: {"admin", "changeit"} diff --git a/guides/security.md b/guides/security.md new file mode 100644 index 0000000..5a512b2 --- /dev/null +++ b/guides/security.md @@ -0,0 +1,238 @@ +# Security + +EventStoreDB has a few options for security which are now all enabled by +default in EventStoreDB 20+: + +- connections over TLS (SSL/HTTPS) +- username+password basic-auth credentials +- access control lists (ACLs) on streams or globally to an EventStoreDB + +By default in EventStoreDB 20+, these are all enabled. The EventStoreDB +may be run with the `--insecure` command line flag to disable all of them +together. + +## Setting up an EventStoreDB for security + +The EventStore documentation now includes a helpful configuration wizard which +will help set up custom or cloud installations of EventStoreDB with the proper +networking, config, clustering, and certificates. For any production use-case, +see the EventStore documentation. + +The repository for Spear includes a `certs/` directory with some generated +certificates. Note that the private keys are included in the repository, so the +certificates in that directory **are not suitable for any real-world case**. +Please only use them on a local machine not connected to the internet. + +Assuming that you're running a Linux machine with the `eventstore-oss` +package installed, we can install the certificates in this repository like so. +Note that these commands will probably need to be run through `sudo`. + +```console +$ cd /path/to/spear +$ mkdir -p /etc/eventstore/certs +$ cp -r ./certs/ca /etc/eventstore/certs/ +$ cp ./certs/node1/* /etc/eventstore/certs +$ cp ./certs/eventstore.conf /etc/eventstore/ +$ chown -R eventstore /etc/eventstore/ +$ chgrp -R eventstore /etc/eventstore/ +$ systemctl restart eventstore +``` + +Now the EventStoreDB should be set up to force TLS connections. You may +need to edit the existing service description to remove the `--insecure` flag +if present. + +## Using custom TLS certificates with Spear + +A `Spear.Connection` takes an `:opts` option which is passed to +`Mint.HTTP.connect/4`. We can inform mint of our custom `./certs/ca/ca.crt` +CA certificate like so: + +```elixir +connection_config = [ + connection_string: "esdb://localhost:2113?tls=true", + opts: [ + transport_opts: [ + cacertfile: Path.join([__DIR__ | ~w(certs ca ca.crt)]) + ] + ] +] + +# as a supervisor spec: +{Spear.Connection, connection_config} + +# or as a direct call of start_link +iex> {:ok, conn} = Spear.Connection.start_link(connection_config) +``` + +Note that this same configuration works with a `Spear.Client` with this +configuration in application-config (`config/*.exs`). + +If you're following along with the guide and have set up an EventStoreDB with +the certificates from the `certs/` directory in the spear repository, you +should now be able to spawn a connection which will force a TLS connection. + +## Using TLS certificates signed by a public CA + +Certificates signed by a public certificate authority (CA) such as the Domain +Validation (DV) certificates produced by the wonderful Letsencrypt project +should work out-of-the-box if the [`castore`](https://hex.pm/packages/castore) +dependency is included in your project. + +```elixir +# mix.exs + .. + def deps do + [ + {:spear, "~> 0.1"}, + {:castore, ">= 0.0.0"} + ] + end + .. +``` + +With `castore`, you should not need to pass any `transport_opts` to Mint. + +```elixir +connection_config = [ + connection_string: "esdb://localhost:2113?tls=true" +] +``` + +## Credentials + +Now that TLS is enabled, we can safely pass basic-auth credentials over the +network. `Spear.Connection` accepts a `:credentials` option as a two-tuple +of `{username, password}`. E.g. with the default login credentials, a +connection can be configured like so: + +```elixir +connection_config = [ + connection_string: "esdb://localhost:2113?tls=true", + credentials: {"admin", "changeit"}, + opts: [ + transport_opts: [ + cacertfile: Path.join([__DIR__ | ~w(certs ca ca.crt)]) + ] + ] +] +``` + +Credentials can also be passed on a per-request basis for all core Spear +functions (except `Spear.ping/2`; pings do not require/allow authentication +as they are not actual requests). All core functions allow a `:credentials` +option two-tuple of `{username, password}` which override the connection-level +credentials if provided. + +E.g. reading a stream as a user named "Aladdin" (assuming such a user exists): + +```elixir +# say we use `connection_config` from above: the connection has credentials +# of {"admin", "changeit"} +iex> {:ok, conn} = Spear.Connection.start_link(connection_config) +# the :credentials option overrides the connection-level credentials +iex> Spear.stream!(conn, "my_stream", credentials: {"Aladdin", "open sesame"}) |> Enum.take(1) +[%Spear.Event{}] +``` + +## Access control lists + +Now that we know how to operate the client as a user, how does EventStoreDB +use credentials to allow or deny access to resources? + +EventStoreDB uses access control lists (ACLs) to allow or deny access to +various operations per user or group. + +ACLs (like virtually everything in EventStoreDB) are just events in a stream. +A simple ACL event body might look like + +```json +{ + "$acl": { + "$w": "$admins", + "$r": "$all", + "$d": "$admins", + "$mw": "$admins", + "$mr": "$admins" + } +} +``` + +What do each of these mean? + +| value | resource | +|------|---------| +| `$w` | write events to this stream | +| `$r` | read events from this stream | +| `$d` | delete this stream | +| `$mw` | write metadata associated with this stream | +| `$mr` | read metadata associated with this stream | +| `$admins` | the group of admin users | +| `$all` | all users (including anonymous users) | + +Note that the ACL is controlled by writing stream metadata, so the `$mw` +permission allows a user to change the ACL of a stream. + +### The global ACL + +The `$streams` system stream may be used to change the default ACL applied to +all streams. By default, the global ACL is + +```json +{ + "$userStreamAcl": { + "$r": "$all", + "$w": "$all", + "$d": "$all", + "$mr": "$all", + "$mw": "$all" + }, + "$systemStreamAcl": { + "$r": "$admins", + "$w": "$admins", + "$d": "$admins", + "$mr": "$admins", + "$mw": "$admins" + } +} +``` + +Where `$systemStreamAcl` applies to projected and otherwise system-created +streams and `$userStreamAcl` applies to all other (user-created) streams. + +**This default is quite permissive**: any user including clients that do not +supply any credentials can read and write events to user-streams with the +default ACL. + +The global ACL can be changed by writing an event of type `update-default-acl` +with a content type of `application/vnd.eventstore.events+json` with the above +body to the `$streams` system stream. + +Spear provides the `Spear.Acl` struct and `Spear.set_global_acl/4` function +to set this without dealing with the nitty-gritty details of the structure +of that event. + +Attempting to access a resource with incorrect or invalid credentials will +yield an HTTP 401 error. + +```elixir +iex> Spear.set_global_acl(conn, Spear.Acl.admins_only(), Spear.Acl.admins_only()) +:ok +iex> Spear.append([my_event], conn, "some_stream", credentials: {"no one", "no pass"}) +{:error, + %Spear.Grpc.Response{ + data: "", + message: "Bad HTTP status code: 401, should be 200", + status: :unknown, + status_code: 2 + }} +``` + +Attempting to access a resource with no credentials will yield a gRPC error +with a status of `:permission_denied`. + +### Stream-level ACLs + +EventStoreDB also allows more fine-grained stream-level ACLs to be defined on +a per-stream basis. See the `Spear.set_stream_metadata/4` function for an +example of setting a stream-level ACL. diff --git a/lib/spear.ex b/lib/spear.ex index 7e00e5f..bdd65b0 100644 --- a/lib/spear.ex +++ b/lib/spear.ex @@ -112,8 +112,12 @@ defmodule Spear do * `:raw?:` - (default: `false`) controls whether or not the enumerable `event_stream` is decoded to `Spear.Event` structs from their raw `ReadReq` output. Setting `raw?: true` prevents this transformation and - leaves each event as a `ReadReq` struct. See + leaves each event as a `ReadReq` record. See `Spear.Event.from_read_response/2` for more information. + * `:credentials` - (default: `nil`) a two-tuple `{username, password}` to + use as credentials for the request. This option overrides any credentials + set in the connection configuration, if present. See the + [Security guide](guides/security.md) for more details. ## Enumeration Characteristics @@ -169,7 +173,8 @@ defmodule Spear do resolve_links?: true, through: fn stream -> Stream.map(stream, &Spear.Event.from_read_response/1) end, timeout: 5_000, - raw?: false + raw?: false, + credentials: nil ] opts = Keyword.merge(default_stream_opts, opts) @@ -235,8 +240,12 @@ defmodule Spear do * `:raw?:` - (default: `false`) controls whether or not the enumerable `event_stream` is decoded to `Spear.Event` structs from their raw `ReadReq` output. Setting `raw?: true` prevents this transformation and - leaves each event as a `ReadReq` struct. See + leaves each event as a `ReadReq` record. See `Spear.Event.from_read_response/2` for more information. + * `:credentials` - (default: `nil`) a two-tuple `{username, password}` to + use as credentials for the request. This option overrides any credentials + set in the connection configuration, if present. See the + [Security guide](guides/security.md) for more details. ## Timing and Timeouts @@ -295,7 +304,8 @@ defmodule Spear do resolve_links?: true, through: fn stream -> Stream.map(stream, &Spear.Event.from_read_response/1) end, timeout: 5_000, - raw?: false + raw?: false, + credentials: nil ] opts = Keyword.merge(default_read_opts, opts) @@ -356,6 +366,10 @@ defmodule Spear do metadata and information from the append response which is not available through the simplified return API, such as the stream's revision number after writing the events. + * `:credentials` - (default: `nil`) a two-tuple `{username, password}` to + use as credentials for the request. This option overrides any credentials + set in the connection configuration, if present. See the + [Security guide](guides/security.md) for more details. ## Examples @@ -376,10 +390,10 @@ defmodule Spear do def append(event_stream, conn, stream_name, opts \\ []) when is_binary(stream_name) do # YARD gRPC timeout? default_write_opts = [ - batch_size: 1, expect: :any, timeout: 5000, - raw?: false + raw?: false, + credentials: nil ] opts = @@ -451,6 +465,10 @@ defmodule Spear do to confirm the subscription request. * `:raw?` - (default: `false`) controls whether the events are sent as raw `ReadResp` records or decoded into `t:Spear.Event.t/0`s + * `:credentials` - (default: `nil`) a two-tuple `{username, password}` to + use as credentials for the request. This option overrides any credentials + set in the connection configuration, if present. See the + [Security guide](guides/security.md) for more details. ## Examples @@ -493,7 +511,8 @@ defmodule Spear do resolve_links?: true, timeout: 5_000, raw?: false, - through: &Spear.Reading.decode_read_response/1 + through: &Spear.Reading.decode_read_response/1, + credentials: nil ] opts = @@ -589,6 +608,10 @@ defmodule Spear do * `:expect` - (default: `:any`) the expected state of the stream when performing the deleteion. See `append/4` and `Spear.ExpectationViolation` for more information. + * `:credentials` - (default: `nil`) a two-tuple `{username, password}` to + use as credentials for the request. This option overrides any credentials + set in the connection configuration, if present. See the + [Security guide](guides/security.md) for more details. ## Examples @@ -609,7 +632,8 @@ defmodule Spear do default_delete_opts = [ tombstone?: false, timeout: 5_000, - expect: :any + expect: :any, + credentials: nil ] opts = @@ -646,4 +670,158 @@ defmodule Spear do @doc since: "0.1.2" @spec ping(connection :: Spear.Connection.t(), timeout()) :: :pong | {:error, any()} def ping(conn, timeout \\ 5_000), do: Connection.call(conn, :ping, timeout) + + @doc """ + Sets the global stream ACL + + This function appends an event to the `$streams` EventStoreDB stream + detailing how the EventStoreDB should allow access to user and system + streams (with the `user_acl` and `system_acl` arguments, respectively). + + See the [security guide](guides/security.md) for more information. + + ## Options + + * `:json_encode!` - (default: `Jason.encode!/1`) a 1-arity JSON encoding + function used to serialize the event. This event must be JSON encoded + in order for the EventStoreDB to consider it valid. + + Remaining options are passed to `Spear.append/4`. The `:expect` option + will be applied to the `$streams` system stream, so one could attempt to + set the initial ACL by passing `expect: :empty`. + + ## Examples + + This recreates the default ACL: + + iex> Spear.set_global_acl(conn, Spear.Acl.allow_all(), Spear.Acl.admins_only()) + :ok + """ + @doc since: "0.1.3" + @spec set_global_acl( + connection :: Spear.Connection.t(), + user_acl :: Spear.Acl.t(), + system_acl :: Spear.Acl.t(), + opts :: Keyword.t() + ) :: :ok | {:error, any()} + def set_global_acl(conn, user_acl, system_acl, opts \\ []) + + def set_global_acl(conn, %Spear.Acl{} = user_acl, %Spear.Acl{} = system_acl, opts) do + {json_encode!, opts} = Keyword.pop(opts, :json_encode!) + json_encode! = json_encode! || (&Jason.encode!/1) + + Spear.Writing.build_global_acl_event(user_acl, system_acl, json_encode!) + |> List.wrap() + |> Spear.append(conn, "$streams", opts) + end + + @doc """ + Determines the metadata stream for any given stream + + Meta streams are used by the EventStoreDB to store some internal information + about a stream, and to configure features such setting time-to-lives for + events or streams. + + ## Examples + + iex> Spear.meta_stream("es_supported_clients") + "$$es_supported_clients" + """ + @doc since: "0.1.3" + @spec meta_stream(stream :: String.t()) :: String.t() + def meta_stream(stream) when is_binary(stream), do: "$$" <> stream + + @doc """ + Queries the metadata for a stream + + Note that the `stream` argument is passed through `meta_stream/1` before + being read. It is not necessary to call that function on the stream name + before passing it as `stream`. + + If no metadata has been set on a stream `{:error, :unset}` is returned. + + ## Options + + Under the hood, `get_stream_metadata/3` uses `read_stream/3` and all options + are passed directly to that function. These options are overridden, however, + and cannot be changed: + + * `:direction` + * `:from` + * `:max_count` + * `:raw?` + + ## Examples + + iex> Spear.get_stream_metadata(conn, "my_stream") + {:error, :unset} + iex> Spear.get_stream_metadata(conn, "some_stream_with_max_count") + {:ok, %Spear.StreamMetadata{max_count: 50_000, ..}} + """ + @doc since: "0.1.3" + @spec get_stream_metadata( + connection :: Spear.Connection.t(), + stream :: String.t(), + opts :: Keyword.t() + ) :: {:ok, Spear.StreamMetadata.t()} | {:error, any()} + def get_stream_metadata(conn, stream, opts \\ []) do + stream = meta_stream(stream) + + opts = + opts + |> Keyword.merge( + direction: :backwards, + from: :end, + max_count: 1, + raw?: false + ) + + with {:ok, event_stream} <- read_stream(conn, stream, opts), + [%Spear.Event{} = event] <- Enum.take(event_stream, 1) do + {:ok, Spear.StreamMetadata.from_spear_event(event)} + else + [] -> + {:error, :unset} + + # coveralls-ignore-start + {:error, reason} -> + {:error, reason} + # coveralls-ignore-stop + end + end + + @doc """ + Sets a stream's metadata + + Note that the `stream` argument is passed through `meta_stream/1` before + being read. It is not necessary to call that function on the stream name + before passing it as `stream`. + + ## Options + + This function uses `append/4` under the hood. All options are passed to + the `opts` argument of `append/4`. + + ## Examples + + # only allow admins to read, write, and delete the stream (or stream metadata) + iex> metadata = %Spear.StreamMetadata{acl: Spear.Acl.admins_only()} + iex> Spear.set_stream_metadata(conn, stream, metadata) + :ok + """ + @doc since: "0.1.3" + @spec set_stream_metadata( + connection :: Spear.Connection.t(), + stream :: String.t(), + metadata :: Spear.StreamMetadata.t(), + opts :: Keyword.t() + ) :: :ok | {:error, any()} + def set_stream_metadata(conn, stream, metadata, opts \\ []) + + def set_stream_metadata(conn, stream, %Spear.StreamMetadata{} = metadata, opts) + when is_binary(stream) do + Spear.Event.new("$metadata", Spear.StreamMetadata.to_map(metadata)) + |> List.wrap() + |> append(conn, stream, opts) + end end diff --git a/lib/spear/acl.ex b/lib/spear/acl.ex new file mode 100644 index 0000000..542d8ef --- /dev/null +++ b/lib/spear/acl.ex @@ -0,0 +1,121 @@ +defmodule Spear.Acl do + @moduledoc """ + A struct representing an access control list (ACL) + + See the [Security guide](guides/security.md) for more information on ACLs + """ + + @typedoc """ + An access control list (ACL) type + + See the [Security guide](guides/security.md) for more information on ACLs + + ACLs may provide permissions for a single user/group or a list of + user/groups. + + ## Examples + + iex> Spear.Acl.allow_all() + %Spear.Acl{ + delete: "$all", + metadata_read: "$all", + metadata_write: "$all", + read: "$all", + write: "$all" + } + """ + @typedoc since: "1.3.0" + @type t :: %__MODULE__{ + read: String.t() | [String.t()], + write: String.t() | [String.t()], + delete: String.t() | [String.t()], + metadata_read: String.t() | [String.t()], + metadata_write: String.t() | [String.t()] + } + + @fields ~w[read write delete metadata_read metadata_write]a + + defstruct @fields + + @doc """ + Produces an ACL that allows all users access to all resources + + Note that clients that do not provide credentials at all fall under the + `$all` group. + + ## Examples + + iex> Spear.Acl.allow_all() + %Spear.Acl{ + delete: "$all", + metadata_read: "$all", + metadata_write: "$all", + read: "$all", + write: "$all" + } + """ + def allow_all do + struct(__MODULE__, Enum.zip(@fields, Stream.repeatedly(fn -> "$all" end))) + end + + @doc """ + Produces an ACL that only allows access to all resources to the `$admins` + group + + ## Examples + + iex> Spear.Acl.admins_only() + %Spear.Acl{ + delete: "$admins", + metadata_read: "$admins", + metadata_write: "$admins", + read: "$admins", + write: "$admins" + } + """ + def admins_only do + struct(__MODULE__, Enum.zip(@fields, Stream.repeatedly(fn -> "$admins" end))) + end + + @doc """ + Converts an ACL struct to a map with the keys expected by the EventStoreDB + + This function is used internall by `Spear.set_global_acl/4` to create a + global ACL event body, but may be used to create an acl body on its own. + + ## Examples + + iex> Spear.Acl.allow_all() |> Spear.Acl.to_map() + %{ + "$w" => "$all", + "$r" => "$all", + "$d" => "$all", + "$mw" => "$all", + "$mr" => "$all" + } + """ + @doc since: "0.1.3" + @spec to_map(t()) :: %{String.t() => String.t() | [String.t()]} + def to_map(%__MODULE__{} = acl) do + %{ + "$w" => acl.write, + "$r" => acl.read, + "$d" => acl.delete, + "$mw" => acl.metadata_write, + "$mr" => acl.metadata_read + } + |> Enum.reject(fn {_k, v} -> v == nil end) + |> Enum.into(%{}) + end + + @doc false + def from_map(%{} = acl) do + %__MODULE__{ + read: Map.get(acl, "$r"), + write: Map.get(acl, "$w"), + delete: Map.get(acl, "$d"), + metadata_read: Map.get(acl, "$mr"), + metadata_write: Map.get(acl, "$mw") + } + end +end diff --git a/lib/spear/connection.ex b/lib/spear/connection.ex index 0fab428..0ea5b0e 100644 --- a/lib/spear/connection.ex +++ b/lib/spear/connection.ex @@ -1,4 +1,6 @@ defmodule Spear.Connection do + @default_opts [protocols: [:http2], mode: :active] + @moduledoc """ A GenServer which brokers a connection to an EventStoreDB @@ -37,6 +39,21 @@ defmodule Spear.Connection do name and is only addressable through its PID. * `:connection_string` - (**required**) the connection string to parse containing all connection information + * `:opts` - (default: `#{inspect(@default_opts)}`) a `t:Keyword.t/0` + of options to pass directly to `Mint.HTTP.connect/4`. See the + `Mint.HTTP.connect/4` documentation for a full reference. This can be used + to specify a custom CA certificate when using EventStoreDB in secure mode + (the default in 20+) with a custom set of certificates. The default options + cannot be overridden: explicitly passed `:protocols` or `:mode` will be + ignored. + * `:credentials` - (default: `nil`) a pair (2-element) tuple providing a + username and password to use for authentication with the EventStoreDB. + E.g. the default username+password of `{"admin", "changeit"}`. + + ## TLS/SSL configuration and credentials + + See the [Security guide](guides/security.md) for information about + certificates, credentials, and access control lists (ACLs). ## Examples @@ -57,7 +74,7 @@ defmodule Spear.Connection do @post "POST" @closed %Mint.TransportError{reason: :closed} - defstruct [:config, :conn, requests: %{}] + defstruct [:config, :credentials, :conn, requests: %{}] @typedoc """ A connection process @@ -118,7 +135,10 @@ defmodule Spear.Connection do @impl Connection def init(config) do if valid_config?(config) do - {:connect, config, %__MODULE__{config: config}} + {credentials, config} = Keyword.pop(config, :credentials) + s = %__MODULE__{config: config, credentials: credentials} + + {:connect, :init, s} else Logger.error(""" #{inspect(__MODULE__)} did not find enough information to start a connection. @@ -130,8 +150,8 @@ defmodule Spear.Connection do end @impl Connection - def connect(config, s) do - case do_connect(config) do + def connect(_, s) do + case do_connect(s.config) do {:ok, conn} -> {:ok, %__MODULE__{s | conn: conn}} {:error, _reason} -> {:backoff, 500, s} end @@ -203,6 +223,8 @@ defmodule Spear.Connection do end def handle_call({type, request}, from, s) do + request = Spear.Request.merge_credentials(request, s.credentials) + case request_and_stream_body(s, request, from, type) do {:ok, s} -> {:noreply, s} @@ -317,11 +339,26 @@ defmodule Spear.Connection do config |> Keyword.fetch!(:connection_string) |> URI.parse() - |> set_esdb_scheme() + |> set_scheme() - Mint.HTTP.connect(uri.scheme, uri.host, uri.port, protocols: [:http2], mode: :active) + opts = + config + |> Keyword.get(:opts, []) + |> Keyword.merge(@default_opts) + + Mint.HTTP.connect(uri.scheme, uri.host, uri.port, opts) end - defp set_esdb_scheme(%URI{scheme: "esdb"} = uri), do: %URI{uri | scheme: :http} - defp set_esdb_scheme(%URI{scheme: "http"} = uri), do: %URI{uri | scheme: :http} + defp set_scheme(%URI{} = uri) do + scheme = + with query when is_binary(query) <- uri.query, + params = URI.decode_query(query), + {:ok, "true"} <- Map.fetch(params, "tls") do + :https + else + _ -> :http + end + + %URI{uri | scheme: scheme} + end end diff --git a/lib/spear/reading.ex b/lib/spear/reading.ex index 70676f6..9bae10b 100644 --- a/lib/spear/reading.ex +++ b/lib/spear/reading.ex @@ -62,7 +62,8 @@ defmodule Spear.Reading do service: :"event_store.client.streams.Streams", service_module: :spear_proto_streams, rpc: :Read, - messages: [message] + messages: [message], + credentials: params.credentials } |> Spear.Request.expand() end diff --git a/lib/spear/reading/stream.ex b/lib/spear/reading/stream.ex index 1e2763c..46b5770 100644 --- a/lib/spear/reading/stream.ex +++ b/lib/spear/reading/stream.ex @@ -9,7 +9,8 @@ defmodule Spear.Reading.Stream do :direction, :resolve_links?, :timeout, - :buffer + :buffer, + :credentials ] @type t :: %__MODULE__{} @@ -76,7 +77,11 @@ defmodule Spear.Reading.Stream do defp request(state) do request = Reading.build_read_request(state) - GenServer.call(state.connection, {:request, build_request(request)}, state.timeout) + GenServer.call( + state.connection, + {:request, build_request(request, state.credentials)}, + state.timeout + ) end defp request!(state) do @@ -85,12 +90,13 @@ defmodule Spear.Reading.Stream do response end - defp build_request(message) do + defp build_request(message, credentials) do %Spear.Request{ service: :"event_store.client.streams.Streams", service_module: :spear_proto_streams, rpc: :Read, - messages: [message] + messages: [message], + credentials: credentials } |> Spear.Request.expand() end diff --git a/lib/spear/request.ex b/lib/spear/request.ex index 5f009a9..eb2eccc 100644 --- a/lib/spear/request.ex +++ b/lib/spear/request.ex @@ -9,7 +9,8 @@ defmodule Spear.Request do :rpc, :path, :headers, - :messages + :messages, + :credentials ] def expand(%__MODULE__{service: service, service_module: service_module, rpc: rpc} = request) do @@ -31,8 +32,17 @@ defmodule Spear.Request do # - custom_metadata may come after the headers returned by this function # - this makes `++/2` a good choice for appending custom metadata # - note that custom headers may not begin with "grpc-" - @spec headers() :: [{String.t(), String.t()}] - defp headers do + @spec headers({String.t(), String.t()} | any()) :: [{String.t(), String.t()}] + defp headers(credentials \\ nil) do + maybe_auth_header = + case credentials do + {username, password} -> + [{"authorization", "Basic " <> Base.encode64("#{username}:#{password}")}] + + _ -> + [] + end + [ {"te", "trailers"}, # {"grpc-timeout", "10S"}, @@ -41,7 +51,17 @@ defmodule Spear.Request do {"grpc-accept-encoding", "identity,deflate,gzip"}, {"accept-encoding", "identity"}, {"user-agent", Grpc.user_agent()} - ] + ] ++ maybe_auth_header + end + + def merge_credentials(request, connection_credentials) do + credentials = + case request.credentials do + {_username, _password} -> request.credentials + _ -> connection_credentials + end + + %__MODULE__{request | credentials: credentials, headers: headers(credentials)} end @spec to_wire_data(tuple(), module(), atom()) :: {iodata(), pos_integer()} diff --git a/lib/spear/stream_metadata.ex b/lib/spear/stream_metadata.ex new file mode 100644 index 0000000..1ac6095 --- /dev/null +++ b/lib/spear/stream_metadata.ex @@ -0,0 +1,52 @@ +defmodule Spear.StreamMetadata do + @moduledoc """ + A struct for describing the metadata about a stream + """ + + @reserved_keys ~w[$maxAge $tb $cacheControl $maxCount $acl] + + @typedoc """ + Internal and custom metadata about a stream + + See the EventStoreDB stream metadata documentation for more details. + """ + @typedoc since: "0.1.3" + @type t :: %__MODULE__{ + max_age: pos_integer() | nil, + truncate_before: pos_integer() | nil, + cache_control: pos_integer() | nil, + max_count: pos_integer() | nil, + acl: Spear.Acl.t() | nil, + custom: %{String.t() => any()} | nil + } + + defstruct [:max_age, :truncate_before, :cache_control, :max_count, :acl, :custom] + + @doc """ + Converts a stream metadata struct to a map in the format EventStoreDB expects + """ + def to_map(%__MODULE__{} = metadata) do + %{ + "$maxAge" => metadata.max_age, + "$tb" => metadata.truncate_before, + "$cacheControl" => metadata.cache_control, + "$maxCount" => metadata.max_count, + "$acl" => metadata.acl && Spear.Acl.to_map(metadata.acl) + } + |> Map.merge(metadata.custom || %{}) + |> Enum.reject(fn {_k, v} -> v in [nil, %{}] end) + |> Enum.into(%{}) + end + + @doc false + def from_spear_event(%Spear.Event{type: "$metadata"} = event) do + %__MODULE__{ + max_age: Map.get(event.body, "$maxAge"), + truncate_before: Map.get(event.body, "$tb"), + cache_control: Map.get(event.body, "$cacheControl"), + max_count: Map.get(event.body, "$cacheControl"), + acl: Map.get(event.body, "$acl", %{}) |> Spear.Acl.from_map(), + custom: Map.drop(event.body, @reserved_keys) + } + end +end diff --git a/lib/spear/writing.ex b/lib/spear/writing.ex index 03edd63..d60b94c 100644 --- a/lib/spear/writing.ex +++ b/lib/spear/writing.ex @@ -33,7 +33,8 @@ defmodule Spear.Writing do service: :"event_store.client.streams.Streams", service_module: :spear_proto_streams, rpc: :Append, - messages: messages + messages: messages, + credentials: params.credentials } |> Spear.Request.expand() end @@ -54,7 +55,8 @@ defmodule Spear.Writing do service: :"event_store.client.streams.Streams", service_module: :spear_proto_streams, rpc: :Delete, - messages: [build_delete_message(params)] + messages: [build_delete_message(params)], + credentials: params.credentials } |> Spear.Request.expand() end @@ -64,7 +66,8 @@ defmodule Spear.Writing do service: :"event_store.client.streams.Streams", service_module: :spear_proto_streams, rpc: :Tombstone, - messages: [build_delete_message(params)] + messages: [build_delete_message(params)], + credentials: params.credentials } |> Spear.Request.expand() end @@ -126,4 +129,17 @@ defmodule Spear.Writing do defp map_expected_revision({:expected_revision, revision}), do: revision # shouldn't this be unreachable?!? defp map_expected_revision({:expected_any, empty()}), do: :any + + def build_global_acl_event(%Spear.Acl{} = user_acl, %Spear.Acl{} = system_acl, json_encode!) + when is_function(json_encode!, 1) do + Spear.Event.new( + "update-default-acl", + %{ + "$userStreamAcl" => Spear.Acl.to_map(user_acl), + "$systemStreamAcl" => Spear.Acl.to_map(system_acl) + } + |> json_encode!.(), + content_type: "application/vnd.eventstore.events+json" + ) + end end diff --git a/mix.exs b/mix.exs index 8119a1f..2253444 100644 --- a/mix.exs +++ b/mix.exs @@ -89,7 +89,8 @@ defmodule Spear.MixProject do "CHANGELOG.md", "guides/writing_events.md", "guides/streams.md", - "guides/link_resolution.md" + "guides/link_resolution.md", + "guides/security.md" ], groups_for_extras: [ Guides: Path.wildcard("guides/*.md") diff --git a/test/spear/acl_test.exs b/test/spear/acl_test.exs new file mode 100644 index 0000000..d1d1157 --- /dev/null +++ b/test/spear/acl_test.exs @@ -0,0 +1,4 @@ +defmodule Spear.AclTest do + use ExUnit.Case, async: true + doctest Spear.Acl +end diff --git a/test/spear/connection_test.exs b/test/spear/connection_test.exs index 687554a..e64bd3d 100644 --- a/test/spear/connection_test.exs +++ b/test/spear/connection_test.exs @@ -3,6 +3,8 @@ defmodule Spear.ConnectionTest do import ExUnit.CaptureLog + @good_config Application.compile_env!(:spear, :config) + describe "given a connection_string leading nowhere" do setup do [connection_string: "esdb://localhost:54325"] @@ -35,7 +37,7 @@ defmodule Spear.ConnectionTest do end test "a connection can be told to disconnect and connect" do - conn = start_supervised!({Spear.Connection, connection_string: "esdb://localhost:2113"}) + conn = start_supervised!({Spear.Connection, @good_config}) assert Connection.call(conn, :close) == {:ok, :closed} assert Process.alive?(conn) @@ -45,7 +47,7 @@ defmodule Spear.ConnectionTest do end test "a connection can noop random info messages" do - conn = start_supervised!({Spear.Connection, connection_string: "esdb://localhost:2113"}) + conn = start_supervised!({Spear.Connection, @good_config}) send(conn, :crypto.strong_rand_bytes(16)) diff --git a/test/spear_test.exs b/test/spear_test.exs index f234642..c37df32 100644 --- a/test/spear_test.exs +++ b/test/spear_test.exs @@ -9,8 +9,10 @@ defmodule SpearTest do @max_append_bytes 1_048_576 @checkpoint_after Integer.pow(32, 3) + @config Application.compile_env!(:spear, :config) + setup do - conn = start_supervised!({Spear.Connection, connection_string: "http://localhost:2113"}) + conn = start_supervised!({Spear.Connection, @config}) [ conn: conn, @@ -109,6 +111,9 @@ defmodule SpearTest do assert Spear.delete_stream(c.conn, c.stream_name) == :ok assert Spear.stream!(c.conn, c.stream_name) |> Enum.to_list() == [] + + assert {:ok, metadata} = Spear.get_stream_metadata(c.conn, c.stream_name) + assert metadata.truncate_before |> is_integer() end test "a deletion request will fail if the expectation mismatches", c do @@ -374,6 +379,34 @@ defmodule SpearTest do :ok = Spear.append([event], c.conn, c.stream_name) assert [%Spear.Event{body: ^body}] = Spear.stream!(c.conn, c.stream_name) |> Enum.to_list() end + + test "we may set the global ACL and then an unauthenticated user access fails", c do + assert Spear.set_global_acl(c.conn, Spear.Acl.admins_only(), Spear.Acl.admins_only()) == :ok + + assert {:error, reason} = + [random_event()] + |> Spear.append(c.conn, c.stream_name, credentials: {"no one", "no pass"}) + + assert reason.message == "Bad HTTP status code: 401, should be 200" + + # reset ACL + assert Spear.set_global_acl(c.conn, Spear.Acl.allow_all(), Spear.Acl.admins_only()) == :ok + end + + test "we map set a local ACL and then an unauthenticated user access fails", c do + metadata = %Spear.StreamMetadata{acl: Spear.Acl.admins_only()} + assert Spear.set_stream_metadata(c.conn, c.stream_name, metadata) == :ok + + assert {:error, reason} = + [random_event()] + |> Spear.append(c.conn, c.stream_name, credentials: {"no one", "no pass"}) + + assert reason.message == "Bad HTTP status code: 401, should be 200" + + # reset ACL + metadata = %Spear.StreamMetadata{acl: Spear.Acl.allow_all()} + assert Spear.set_stream_metadata(c.conn, c.stream_name, metadata) == :ok + end end defp random_stream_name do