Skip to content

Commit 67dd164

Browse files
committed
RDBC-1085 Keep certificate permission (database-name) keys verbatim on the wire
The command-payload serializer PascalCases the first letter of every object key, including the keys of the certificate `permissions` map, which are database names. A lowercase name like "lowercase_db" was sent as "Lowercase_db", and since the server matches permission keys case-sensitively, restored permissions no longer lined up with the database name. Mirror the C# client, which writes these bodies with PascalCased field names but emits each permission key verbatim: serialize Create/Put/Edit client certificate payloads with the casing-preserving serializer and supply already-PascalCased field names.
1 parent 12df664 commit 67dd164

4 files changed

Lines changed: 188 additions & 9 deletions

File tree

src/ServerWide/Operations/Certificates/CreateClientCertificateOperation.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { RavenCommand } from "../../../Http/RavenCommand.js";
1212
import { ServerNode } from "../../../Http/ServerNode.js";
1313
import { IRaftCommand } from "../../../Http/IRaftCommand.js";
1414
import { RaftIdGenerator } from "../../../Utility/RaftIdGenerator.js";
15+
import { JsonSerializer } from "../../../Mapping/Json/Serializer.js";
1516

1617
export class CreateClientCertificateOperation implements IServerOperation<CertificateRawData> {
1718
private readonly _name: string;
@@ -74,7 +75,10 @@ class CreateClientCertificateCommand extends RavenCommand<CertificateRawData> im
7475
createRequest(node: ServerNode): HttpRequestParameters {
7576
const uri = node.url + "/admin/certificates";
7677

77-
const body = this._serializer
78+
// Field names are already PascalCased here, so use the casing-preserving serializer:
79+
// the default command-payload serializer would also PascalCase the first letter of every
80+
// `permissions` key (database names), breaking case-sensitive permission matching (RDBC-1085).
81+
const body = JsonSerializer.getDefault()
7882
.serialize({
7983
Name: this._name,
8084
SecurityClearance: this._clearance,

src/ServerWide/Operations/Certificates/EditClientCertificateOperation.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { DocumentConventions } from "../../../Documents/Conventions/DocumentConv
88
import { RavenCommand } from "../../../Http/RavenCommand.js";
99
import { IRaftCommand } from "../../../Http/IRaftCommand.js";
1010
import { ServerNode } from "../../../Http/ServerNode.js";
11-
import { CertificateDefinition } from "./CertificateDefinition.js";
11+
import { JsonSerializer } from "../../../Mapping/Json/Serializer.js";
1212

1313
export class EditClientCertificateOperation implements IServerOperation<void> {
1414
private readonly _thumbprint: string;
@@ -74,14 +74,18 @@ class EditClientCertificateCommand extends RavenCommand<void> implements IRaftCo
7474
createRequest(node: ServerNode): HttpRequestParameters {
7575
const uri = node.url + "/admin/certificates/edit";
7676

77+
// Field names are PascalCased explicitly here (rather than via the default command-payload
78+
// serializer) so the casing-preserving serializer can be used: the default one would also
79+
// PascalCase the first letter of every `permissions` key (database names), breaking
80+
// case-sensitive permission matching (RDBC-1085).
7781
const definition = {
78-
thumbprint: this._thumbprint,
79-
permissions: this._permissions,
80-
securityClearance: this._clearance,
81-
name: this._name
82-
} as CertificateDefinition;
82+
Thumbprint: this._thumbprint,
83+
Permissions: this._permissions,
84+
SecurityClearance: this._clearance,
85+
Name: this._name
86+
};
8387

84-
const body = this._serializer.serialize(definition);
88+
const body = JsonSerializer.getDefault().serialize(definition);
8589

8690
return {
8791
method: "POST",

src/ServerWide/Operations/Certificates/PutClientCertificateOperation.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { RavenCommand } from "../../../Http/RavenCommand.js";
99
import { ServerNode } from "../../../Http/ServerNode.js";
1010
import { IRaftCommand } from "../../../Http/IRaftCommand.js";
1111
import { RaftIdGenerator } from "../../../Utility/RaftIdGenerator.js";
12+
import { JsonSerializer } from "../../../Mapping/Json/Serializer.js";
1213

1314
export class PutClientCertificateOperation implements IServerOperation<void> {
1415
private readonly _certificate: string;
@@ -82,7 +83,10 @@ class PutClientCertificateCommand extends RavenCommand<void> implements IRaftCom
8283
createRequest(node: ServerNode): HttpRequestParameters {
8384
const uri = node.url + "/admin/certificates";
8485

85-
const body = this._serializer
86+
// Field names are already PascalCased here, so use the casing-preserving serializer:
87+
// the default command-payload serializer would also PascalCase the first letter of every
88+
// `permissions` key (database names), breaking case-sensitive permission matching (RDBC-1085).
89+
const body = JsonSerializer.getDefault()
8690
.serialize({
8791
Name: this._name,
8892
Certificate: this._certificate,

test/Issues/RDBC-1085.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import assert from "node:assert";
2+
import {
3+
CreateClientCertificateOperation,
4+
PutClientCertificateOperation,
5+
EditClientCertificateOperation,
6+
CreateDatabaseOperation,
7+
DeleteDatabasesOperation,
8+
DeleteCertificateOperation,
9+
GetCertificatesMetadataOperation,
10+
GetDatabaseNamesOperation,
11+
DatabaseAccess,
12+
DocumentConventions,
13+
IDocumentStore
14+
} from "../../src/index.js";
15+
import { disposeTestDocumentStore, testContext } from "../Utils/TestUtil.js";
16+
17+
// RDBC-1085: the client's command-payload serializer PascalCases the first letter of every object
18+
// key, including the keys of the `permissions` map (which are database names). A lowercase database
19+
// name like "lowercase_db" was being sent as "Lowercase_db", and because the server matches permission
20+
// keys case-sensitively, certificate permissions no longer lined up with the database name.
21+
// The permission keys must travel to the server verbatim.
22+
describe("[RDBC-1085]", () => {
23+
24+
const conventions = new DocumentConventions();
25+
const node = { url: "http://localhost:8080", database: "test" } as any;
26+
27+
const permissions = {
28+
"lowercase_db": "Admin",
29+
"mixedCaseDb": "ReadWrite",
30+
"UPPER_db": "Read"
31+
} as Record<string, DatabaseAccess>;
32+
33+
function permissionKeysOf(body: string): string[] {
34+
return Object.keys(JSON.parse(body).Permissions ?? {});
35+
}
36+
37+
it("CreateClientCertificateOperation keeps permission (database-name) keys verbatim", () => {
38+
const command = new CreateClientCertificateOperation("cert", permissions, "ValidUser")
39+
.getCommand(conventions);
40+
const body = command.createRequest(node).body as string;
41+
42+
assert.deepStrictEqual(permissionKeysOf(body), ["lowercase_db", "mixedCaseDb", "UPPER_db"]);
43+
});
44+
45+
it("PutClientCertificateOperation keeps permission (database-name) keys verbatim", () => {
46+
const command = new PutClientCertificateOperation("cert", "public-key", permissions, "ValidUser")
47+
.getCommand(conventions);
48+
const body = command.createRequest(node).body as string;
49+
50+
assert.deepStrictEqual(permissionKeysOf(body), ["lowercase_db", "mixedCaseDb", "UPPER_db"]);
51+
});
52+
53+
it("EditClientCertificateOperation keeps permission (database-name) keys verbatim", () => {
54+
const command = new EditClientCertificateOperation({
55+
thumbprint: "ABC123",
56+
name: "cert",
57+
clearance: "ValidUser",
58+
permissions
59+
}).getCommand(conventions);
60+
const body = command.createRequest(node).body as string;
61+
62+
assert.deepStrictEqual(permissionKeysOf(body), ["lowercase_db", "mixedCaseDb", "UPPER_db"]);
63+
});
64+
65+
it("top-level certificate fields are still PascalCased on the wire", () => {
66+
const command = new CreateClientCertificateOperation("cert", permissions, "ValidUser")
67+
.getCommand(conventions);
68+
const parsed = JSON.parse(command.createRequest(node).body as string);
69+
70+
assert.ok("Name" in parsed, "expected PascalCased 'Name' field");
71+
assert.ok("SecurityClearance" in parsed, "expected PascalCased 'SecurityClearance' field");
72+
assert.ok("Permissions" in parsed, "expected PascalCased 'Permissions' field");
73+
});
74+
});
75+
76+
// Full end-to-end reproduction of the ticket flow against a secured server. Certificate operations
77+
// require HTTPS + a client certificate, so this uses a secured document store.
78+
describe("[RDBC-1085] full flow", function () {
79+
80+
let store: IDocumentStore;
81+
82+
beforeEach(async function () {
83+
store = await testContext.getSecuredDocumentStore();
84+
});
85+
86+
afterEach(async () =>
87+
await disposeTestDocumentStore(store));
88+
89+
it("preserves lowercase database-name casing across delete/recreate and keeps certificate permissions aligned", async function () {
90+
this.timeout(60_000);
91+
92+
// The casing our applications and client certificates expect.
93+
const requestedName = "lowercase_db";
94+
const certName = "RDBC-1085-full-flow-cert";
95+
96+
let thumbprint: string;
97+
98+
try {
99+
// Register a client certificate whose permissions are keyed to the lowercase database name.
100+
await store.maintenance.server.send(new CreateClientCertificateOperation(
101+
certName,
102+
{ [requestedName]: "Admin" } as Record<string, DatabaseAccess>,
103+
"ValidUser"));
104+
105+
// Create the database with the lowercase name.
106+
await store.maintenance.server.send(
107+
new CreateDatabaseOperation({ databaseName: requestedName }));
108+
109+
// 1. Capture the target database's certificate permissions.
110+
const metadataBefore = await store.maintenance.server.send(
111+
new GetCertificatesMetadataOperation(certName));
112+
assert.strictEqual(metadataBefore.length, 1);
113+
thumbprint = metadataBefore[0].thumbprint;
114+
const capturedPermissions = metadataBefore[0].permissions ?? {};
115+
116+
// The captured permission key must match the requested (lowercase) database name.
117+
assert.deepStrictEqual(Object.keys(capturedPermissions), [requestedName]);
118+
119+
// 2. Hard-delete the target database.
120+
await store.maintenance.server.send(new DeleteDatabasesOperation({
121+
databaseNames: [requestedName],
122+
hardDelete: true
123+
}));
124+
125+
// 3. Recreate it using the same lowercase name.
126+
await store.maintenance.server.send(
127+
new CreateDatabaseOperation({ databaseName: requestedName }));
128+
129+
// 4. Restore the captured certificate permissions.
130+
await store.maintenance.server.send(new EditClientCertificateOperation({
131+
thumbprint,
132+
name: metadataBefore[0].name,
133+
permissions: capturedPermissions,
134+
clearance: metadataBefore[0].securityClearance
135+
}));
136+
137+
// The recreated database must keep the exact casing we requested.
138+
const databaseNames = await store.maintenance.server.send(
139+
new GetDatabaseNamesOperation(0, 100));
140+
assert.ok(databaseNames.includes(requestedName),
141+
`expected database '${requestedName}' but got ${JSON.stringify(databaseNames)}`);
142+
143+
// The restored permission key must still line up (case-sensitively) with the database name.
144+
const metadataAfter = await store.maintenance.server.send(
145+
new GetCertificatesMetadataOperation(certName));
146+
assert.strictEqual(metadataAfter.length, 1);
147+
assert.deepStrictEqual(Object.keys(metadataAfter[0].permissions ?? {}), [requestedName]);
148+
} finally {
149+
// Clean up the recreated database and the test certificate.
150+
try {
151+
await store.maintenance.server.send(new DeleteDatabasesOperation({
152+
databaseNames: [requestedName],
153+
hardDelete: true
154+
}));
155+
} catch {
156+
// ignore
157+
}
158+
if (thumbprint) {
159+
try {
160+
await store.maintenance.server.send(new DeleteCertificateOperation(thumbprint));
161+
} catch {
162+
// ignore
163+
}
164+
}
165+
}
166+
});
167+
});

0 commit comments

Comments
 (0)