Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 68 additions & 5 deletions .github/workflows/functional-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,28 +79,85 @@ jobs:
run: |
TAG=${INPUT_TAG}

EA_VERSION=2.0.1-1231
EA_VERSION=2.2.0-1166
export EA_VERSION

cat > cluster.yml <<'YAML'
columnar: true
nodes:
- count: 2
version: ${EA_VERSION}
docker:
load-balancer: true
passive-load-balancer: true
use-dino-certs: true
jwt: true
YAML

# Substitute shell vars into YAML
eval "echo \"$(cat cluster.yml)\"" > cluster.resolved.yml
# Substitute shell vars (e.g. EA_VERSION) into YAML.
# envsubst preserves YAML structure better than eval+echo.
envsubst < cluster.yml > cluster.resolved.yml
echo "Resolved cluster definition:" && cat cluster.resolved.yml

CBDINO_CLUSTER_ID=$(cbdinocluster -v alloc --def="$(cat cluster.resolved.yml)")

# Connection string through the load balancer (used by most tests)
CBDINO_CONNSTR=$(cbdinocluster -v connstr --tls --analytics "$CBDINO_CLUSTER_ID")

# Direct-to-node connection string (required for mTLS tests because
# the nginx passive load balancer terminates TLS at L7, so client
# certificates are not forwarded to the analytics service).
# Extract the first cluster node IP from cbdinocluster ps --json.
NODE_IP=$(cbdinocluster ps --json 2>/dev/null \
| python3 -c "import sys,json; data=json.load(sys.stdin); [print(n['ip_address']) or sys.exit(0) for c in data for n in c.get('nodes',[]) if n.get('is_cluster_node')]" 2>/dev/null \
|| echo "")
if [ -n "$NODE_IP" ]; then
CBDINO_CONNSTR_DIRECT="https://${NODE_IP}:18095"
else
# No LB or couldn't parse — fall back to the primary connstr
CBDINO_CONNSTR_DIRECT="$CBDINO_CONNSTR"
fi

echo "CBDINO_CLUSTER_ID=$CBDINO_CLUSTER_ID" >> "$GITHUB_ENV"
echo "CBDINO_CONNSTR=$CBDINO_CONNSTR" >> "$GITHUB_ENV"
echo "CBDINO_CONNSTR_DIRECT=$CBDINO_CONNSTR_DIRECT" >> "$GITHUB_ENV"
echo "CBDINO_USER=Administrator" >> "$GITHUB_ENV"
echo "CBDINO_PASS=password" >> "$GITHUB_ENV"
echo "Load balancer connstr: $CBDINO_CONNSTR"
echo "Direct node connstr: $CBDINO_CONNSTR_DIRECT"

- name: Create JWT test user and generate token
run: |
echo "Creating jwt-test-user on cluster $CBDINO_CLUSTER_ID"
cbdinocluster -v users add "$CBDINO_CLUSTER_ID" jwt-test-user \
--password testpass --can-read --can-write

echo "Generating JWT for jwt-test-user"
CBDINO_JWT=$(cbdinocluster jwt generate jwt-test-user --can-read --can-write)
echo "CBDINO_JWT=$CBDINO_JWT" >> "$GITHUB_ENV"

- name: Generate mTLS client certificates
run: |
echo "Creating mtls-swap-user for certificate swap test"
cbdinocluster -v users add "$CBDINO_CLUSTER_ID" mtls-swap-user \
--password testpass --can-read --can-write

echo "Generating client certificate for Administrator"
CERT_OUTPUT=$(cbdinocluster certificates get-client-cert Administrator)

# cbdinocluster outputs PEM cert followed by PEM key
echo "$CERT_OUTPUT" | awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' > /tmp/client-cert.pem
echo "$CERT_OUTPUT" | awk '/BEGIN RSA PRIVATE KEY/,/END RSA PRIVATE KEY/' > /tmp/client-key.pem

echo "Generating client certificate for mtls-swap-user"
CERT_OUTPUT_2=$(cbdinocluster certificates get-client-cert mtls-swap-user)

echo "$CERT_OUTPUT_2" | awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' > /tmp/client-cert-2.pem
echo "$CERT_OUTPUT_2" | awk '/BEGIN RSA PRIVATE KEY/,/END RSA PRIVATE KEY/' > /tmp/client-key-2.pem

echo "CBDINO_CLIENT_CERT_PATH=/tmp/client-cert.pem" >> "$GITHUB_ENV"
echo "CBDINO_CLIENT_KEY_PATH=/tmp/client-key.pem" >> "$GITHUB_ENV"
echo "CBDINO_CLIENT_CERT_PATH_2=/tmp/client-cert-2.pem" >> "$GITHUB_ENV"
echo "CBDINO_CLIENT_KEY_PATH_2=/tmp/client-key-2.pem" >> "$GITHUB_ENV"

- name: Prepare Functional Test settings.json
run: |
Expand All @@ -109,8 +166,14 @@ jobs:
{
"TestSettings": {
"ConnectionString": "${CBDINO_CONNSTR}",
"DirectConnectionString": "${CBDINO_CONNSTR_DIRECT}",
"Username": "${CBDINO_USER}",
"Password": "${CBDINO_PASS}"
"Password": "${CBDINO_PASS}",
"JwtToken": "${CBDINO_JWT}",
"ClientCertPath": "${CBDINO_CLIENT_CERT_PATH}",
"ClientKeyPath": "${CBDINO_CLIENT_KEY_PATH}",
"ClientCertPath2": "${CBDINO_CLIENT_CERT_PATH_2}",
"ClientKeyPath2": "${CBDINO_CLIENT_KEY_PATH_2}"
}
}
EOF
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ var cluster = Cluster.Create(
);
```

Or authenticate with a JWT:

```csharp
var cluster = Cluster.Create(
connectionString: "https://analytics.my-couchbase.example.com:18095",
credential: JwtCredential.Create("xxxxx.yyyyy.zzzzz")
);
```

Or use mutual TLS (mTLS) with a client certificate:

```csharp
var cluster = Cluster.Create(
connectionString: "https://analytics.my-couchbase.example.com:18095",
credential: CertificateCredential.FromPkcs12("/path/to/client.pfx", "password")
// or: CertificateCredential.FromPem("/path/to/cert.pem", "/path/to/key.pem")
);
```

> [!NOTE]
> Use `http://host:8095` for non-TLS connections, `https://host:18095` for TLS (or your own custom ports for a load balancer or proxy)
>
Expand Down
46 changes: 46 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,52 @@ var cluster = Cluster.Create(
);
```

#### JWT Authentication

To authenticate with a JSON Web Token (JWT) instead of username and password:

```csharp
var credential = JwtCredential.Create("xxxxx.yyyyy.zzzzz");

var cluster = Cluster.Create(
connectionString: "https://analytics.my-couchbase.example.com:18095",
credential: credential
);
```

#### Mutual TLS (mTLS) Authentication

To authenticate with a client certificate during the TLS handshake:

```csharp
// From a PKCS#12 (.pfx / .p12) file
var credential = CertificateCredential.FromPkcs12("/path/to/client.pfx", "password");

// Or from PEM-encoded certificate and key files
var credential = CertificateCredential.FromPem("/path/to/cert.pem", "/path/to/key.pem");

var cluster = Cluster.Create(
connectionString: "https://analytics.my-couchbase.example.com:18095",
credential: credential
);
```

> [!NOTE]
> The client certificate must contain a private key. When using mTLS, no HTTP `Authorization` header
> is sent — authentication is handled entirely during the TLS handshake.
Comment thread
davidkelly marked this conversation as resolved.
Outdated

#### Updating Credentials

After the cluster is created, you can supply a new credential (of the same type) for all subsequent requests:

```csharp
cluster.UpdateCredential(Credential.Create("newuser", "newpassword"));
// or
cluster.UpdateCredential(JwtCredential.Create("new.jwt.token"));
// or
cluster.UpdateCredential(CertificateCredential.FromPkcs12("/path/to/new-client.pfx", "password"));
```

> [!NOTE]
> Use `http://host:8095` for non-TLS connections, `https://host:18095` for TLS (or your own custom ports for a load balancer or proxy)
>
Expand Down
60 changes: 52 additions & 8 deletions src/Couchbase.Analytics/Cluster.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ namespace Couchbase.AnalyticsClient;

public class Cluster : IDisposable
{
private readonly Credential _credential;
private volatile ICredential _credential;
private readonly ClusterOptions _clusterOptions;
private readonly ILogger<Cluster> _logger;
private readonly ICouchbaseServiceProvider _serviceProvider;
private readonly LazyService<IAnalyticsService> _analyticsService;
private readonly ConcurrentDictionary<string, Database> _databases = new();

private Cluster(Credential credential, ClusterOptions clusterOptions)
private Cluster(ICredential credential, ClusterOptions clusterOptions)
{
if (string.IsNullOrWhiteSpace(clusterOptions.ConnectionString))
{
Expand All @@ -48,11 +48,10 @@ private Cluster(Credential credential, ClusterOptions clusterOptions)

_credential = credential ?? throw new ArgumentNullException(nameof(credential));
_clusterOptions = clusterOptions ?? throw new ArgumentNullException(nameof(clusterOptions));
_serviceProvider = clusterOptions.BuildServiceProvider(_credential);
_serviceProvider = clusterOptions.BuildServiceProvider(() => _credential);

_logger = _serviceProvider.GetRequiredService<ILogger<Cluster>>();
_analyticsService = new LazyService<IAnalyticsService>(_serviceProvider);

}

/// <summary>
Expand All @@ -63,7 +62,7 @@ private Cluster(Credential credential, ClusterOptions clusterOptions)
/// <param name="configureOptions">Action to configure cluster options</param>
/// <returns>A Cluster instance</returns>
/// <exception cref="ArgumentException">Thrown when the connection string is null or empty, or the credential is null</exception>
public static Cluster Create(string connectionString, Credential credential, Func<ClusterOptions, ClusterOptions> configureOptions)
public static Cluster Create(string connectionString, ICredential credential, Func<ClusterOptions, ClusterOptions> configureOptions)
{
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString));
Expand All @@ -87,7 +86,7 @@ public static Cluster Create(string connectionString, Credential credential, Fun
/// <param name="clusterOptions">The cluster options to use for the cluster</param>
/// <returns>A Cluster instance</returns>
/// <exception cref="ArgumentException">Thrown when the connection string is null or empty, or the credential is null</exception>
public static Cluster Create(string connectionString, Credential credential, ClusterOptions clusterOptions)
public static Cluster Create(string connectionString, ICredential credential, ClusterOptions clusterOptions)
{
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString));
Expand All @@ -106,7 +105,7 @@ public static Cluster Create(string connectionString, Credential credential, Clu
/// <param name="credential">The credentials to use for authentication</param>
/// <returns>A Cluster instance</returns>
/// <exception cref="ArgumentException">Thrown when the connection string is null or empty, or the credential is null</exception>
public static Cluster Create(string connectionString, Credential credential)
public static Cluster Create(string connectionString, ICredential credential)
{
return Create(connectionString, credential, new ClusterOptions());
}
Expand All @@ -118,14 +117,59 @@ public static Cluster Create(string connectionString, Credential credential)
/// <param name="clusterOptions">Pre-configured cluster options with connection string</param>
/// <returns>A Cluster instance</returns>
/// <exception cref="ArgumentNullException">Thrown when the credential or cluster options are null</exception>
public static Cluster Create(Credential credential, ClusterOptions clusterOptions)
public static Cluster Create(ICredential credential, ClusterOptions clusterOptions)
{
ArgumentNullException.ThrowIfNull(credential);
ArgumentNullException.ThrowIfNull(clusterOptions);

return new Cluster(credential, clusterOptions);
}

// ── Binary-compatible forwarding overloads ──────────────────────────
// These preserve the exact method signatures from before the ICredential
// widening, so that existing compiled code continues to resolve correctly
// at runtime without recompilation.
Comment thread
davidkelly marked this conversation as resolved.
Outdated

/// <summary>
/// Creates a cluster with a connection string, username/password credential, and an options builder.
/// </summary>
public static Cluster Create(string connectionString, Credential credential, Func<ClusterOptions, ClusterOptions> configureOptions)
=> Create(connectionString, (ICredential)credential, configureOptions);

/// <summary>
/// Creates a cluster with a connection string, username/password credential, and cluster options.
/// </summary>
public static Cluster Create(string connectionString, Credential credential, ClusterOptions clusterOptions)
=> Create(connectionString, (ICredential)credential, clusterOptions);

/// <summary>
/// Creates a cluster with a connection string and username/password credential.
/// </summary>
public static Cluster Create(string connectionString, Credential credential)
=> Create(connectionString, (ICredential)credential);

/// <summary>
/// Creates a cluster with a username/password credential and cluster options.
/// </summary>
public static Cluster Create(Credential credential, ClusterOptions clusterOptions)
=> Create((ICredential)credential, clusterOptions);

/// <summary>
/// Replaces the credential used for all subsequent HTTP requests.
/// Thread-safe. The new credential must be the same type as the current credential.
/// </summary>
/// <param name="newCredential">The new credential to use.</param>
/// <exception cref="InvalidOperationException">Thrown if the new credential is a different type than the current one.</exception>
public void UpdateCredential(ICredential newCredential)
{
ArgumentNullException.ThrowIfNull(newCredential);
var current = _credential;
if (current.GetType() != newCredential.GetType())
throw new InvalidOperationException(
$"Cannot change credential type from {current.GetType().Name} to {newCredential.GetType().Name}.");
_credential = newCredential;
}

public Task<IQueryResult> ExecuteQueryAsync(string statement, Func<QueryOptions, QueryOptions> options, CancellationToken cancellationToken = default)
{
var queryOptions = new QueryOptions();
Expand Down
Loading
Loading